slidemind 0.0.28 → 0.1.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/bin/slidemind.mjs +88 -0
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +8 -9
- package/src/bridge/bundle.mjs +21 -10
- package/src/bridge/client.mjs +42 -33
- package/src/bridge/config.mjs +71 -0
- package/src/bridge/pair.mjs +67 -0
- package/src/builders/resolve-skill.mjs +21 -6
package/bin/slidemind.mjs
CHANGED
|
@@ -59,6 +59,9 @@ function printHelp() {
|
|
|
59
59
|
` --at <index> insert after slide N (0-based); default = append\n` +
|
|
60
60
|
` --replace delete existing slides first (destructive)\n` +
|
|
61
61
|
` --slides <ids> comma-separated source slide ids to push\n` +
|
|
62
|
+
` slidemind pair <code> Pair with an add-in (copy the code from its "Connect CLI")\n` +
|
|
63
|
+
` slidemind whoami Show the paired account\n` +
|
|
64
|
+
` slidemind logout Unpair this machine\n` +
|
|
62
65
|
` slidemind init --for <t> Set up slidemind in a harness (registers the MCP server + skill)\n` +
|
|
63
66
|
` slidemind uninstall --for <t> Remove a previously-installed skill\n` +
|
|
64
67
|
` slidemind update [--for <t>] Fetch the latest release and redeploy\n` +
|
|
@@ -321,6 +324,82 @@ async function cmdPresentations(rest) {
|
|
|
321
324
|
}
|
|
322
325
|
}
|
|
323
326
|
|
|
327
|
+
async function loadBridgeModule(name) {
|
|
328
|
+
return import(pathToFileURL(path.join(SRC_DIR, "bridge", `${name}.mjs`)).href);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function cmdPair(rest) {
|
|
332
|
+
const code = rest.find((a) => !a.startsWith("--"));
|
|
333
|
+
if (!code) {
|
|
334
|
+
fail('usage: slidemind pair <code> (copy the command from the add-in\'s "Connect CLI")');
|
|
335
|
+
}
|
|
336
|
+
const pairMod = await loadBridgeModule("pair");
|
|
337
|
+
const decoded = pairMod.decodePairingCode(code);
|
|
338
|
+
if (!decoded) {
|
|
339
|
+
fail("invalid pairing code — copy the exact `slidemind pair …` command shown in the add-in");
|
|
340
|
+
}
|
|
341
|
+
const res = await pairMod.httpJson(
|
|
342
|
+
"POST",
|
|
343
|
+
`${decoded.backendURL}/api/cli/pair/redeem`,
|
|
344
|
+
{ body: { secret: decoded.secret } },
|
|
345
|
+
);
|
|
346
|
+
if (res.status !== 200 || !res.json?.sessionToken) {
|
|
347
|
+
fail(
|
|
348
|
+
`pairing failed (${res.status}): ${res.json?.error || "no session returned"}\n` +
|
|
349
|
+
"the code may have expired — generate a fresh one in the add-in's \"Connect CLI\"",
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
const cfg = await loadBridgeModule("config");
|
|
353
|
+
cfg.writeSessionConfig({
|
|
354
|
+
backendURL: decoded.backendURL,
|
|
355
|
+
sessionToken: res.json.sessionToken,
|
|
356
|
+
});
|
|
357
|
+
process.stdout.write(
|
|
358
|
+
`Paired with ${decoded.backendURL}. Run 'slidemind presentations' to verify.\n`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function cmdWhoami() {
|
|
363
|
+
const cfg = await loadBridgeModule("config");
|
|
364
|
+
const session = cfg.readSessionConfig();
|
|
365
|
+
if (!session) {
|
|
366
|
+
process.stderr.write(
|
|
367
|
+
'Not paired. Run `slidemind pair <code>` using the add-in\'s "Connect CLI".\n',
|
|
368
|
+
);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const pairMod = await loadBridgeModule("pair");
|
|
372
|
+
const res = await pairMod.httpJson("GET", `${session.backendURL}/api/cli/whoami`, {
|
|
373
|
+
token: session.sessionToken,
|
|
374
|
+
});
|
|
375
|
+
if (res.status === 200 && res.json) {
|
|
376
|
+
process.stdout.write(
|
|
377
|
+
`Paired with ${session.backendURL}\n user: ${res.json.oid}\n tenant: ${res.json.tid ?? "(n/a)"}\n`,
|
|
378
|
+
);
|
|
379
|
+
} else {
|
|
380
|
+
process.stderr.write(
|
|
381
|
+
`Session invalid (${res.status}). Re-pair via the add-in's "Connect CLI".\n`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function cmdLogout() {
|
|
387
|
+
const cfg = await loadBridgeModule("config");
|
|
388
|
+
const session = cfg.readSessionConfig();
|
|
389
|
+
if (session) {
|
|
390
|
+
try {
|
|
391
|
+
const pairMod = await loadBridgeModule("pair");
|
|
392
|
+
await pairMod.httpJson("POST", `${session.backendURL}/api/cli/logout`, {
|
|
393
|
+
token: session.sessionToken,
|
|
394
|
+
});
|
|
395
|
+
} catch {
|
|
396
|
+
/* best-effort server-side revoke; we still clear the local session */
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const cleared = cfg.clearSessionConfig();
|
|
400
|
+
process.stdout.write(cleared ? "Logged out.\n" : "Already logged out.\n");
|
|
401
|
+
}
|
|
402
|
+
|
|
324
403
|
async function cmdPush(rest) {
|
|
325
404
|
const { deck, passthrough } = splitDeckArg(rest, "push");
|
|
326
405
|
let to = null;
|
|
@@ -572,6 +651,15 @@ async function main(argv) {
|
|
|
572
651
|
case "new":
|
|
573
652
|
await cmdNew(rest);
|
|
574
653
|
return 0;
|
|
654
|
+
case "pair":
|
|
655
|
+
await cmdPair(rest);
|
|
656
|
+
break;
|
|
657
|
+
case "whoami":
|
|
658
|
+
await cmdWhoami(rest);
|
|
659
|
+
break;
|
|
660
|
+
case "logout":
|
|
661
|
+
await cmdLogout(rest);
|
|
662
|
+
break;
|
|
575
663
|
case "presentations":
|
|
576
664
|
await cmdPresentations(rest);
|
|
577
665
|
return 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slidemind",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Connect Claude Code, Codex & opencode to the slidemind PowerPoint add-in — let your coding agent build and edit native, on-brand PowerPoint decks against your active corporate design, from the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,18 +13,15 @@ This skill is **thin workflow glue for the local harness**. Everything that dete
|
|
|
13
13
|
|
|
14
14
|
So this file owns only what is genuinely CLI-specific: the **host precondition**, a compact **intake/plan**, the **create flow**, and the **command mapping** that translates the add-in's in-app tool names (used throughout the bundle's `workflow/*.md`) into your `slidemind` commands and MCP tools. Read the workflow + brand from the bundle; never restate or duplicate them here.
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## Bootstrap (set yourself up — don't make the user do it)
|
|
17
17
|
|
|
18
|
-
This skill drives the `slidemind` CLI
|
|
18
|
+
This skill drives the `slidemind` CLI + its MCP server (`slidemind_list_open_presentations`, `slidemind_push_deck`, `slidemind_search_engine`, `slidemind_download_result`). The CLI reaches PowerPoint through the add-in's **cloud relay** — it doesn't run any local server. Patch what's missing yourself, but the **pairing** step needs the user once (they hold the add-in's identity):
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
slidemind
|
|
23
|
-
```
|
|
20
|
+
- **CLI missing** (`slidemind` not on `PATH`, or the `slidemind_*` MCP tools absent): `npm install -g slidemind`, then `slidemind init --for <claude|codex|opencode|all>` (registers the MCP server + (re)deploys this skill). The harness may need a restart for the `slidemind_*` tools to appear.
|
|
21
|
+
- **Not paired** (a command says *slidemind is not paired*): this is the one step you can't do for the user. Tell them: in the slidemind add-in click **"Connect CLI"**, then paste the shown `slidemind pair …` command into this terminal. It's one-time per machine (the code carries the backend URL + a session token — no hardcoded URLs, no separate login; the add-in's own session is the auth). `slidemind whoami` confirms the pairing.
|
|
22
|
+
- `npx slidemind <command>` runs one-off without a global install; `slidemind --version` confirms PATH; `slidemind update` upgrades (`--ref <version>` to pin).
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
- `slidemind --version` confirms it's on `PATH`. `slidemind update` upgrades to the latest npm release (`--ref <version>` to pin).
|
|
27
|
-
- If `slidemind init` just registered the MCP server, the harness may need a restart before the `slidemind_*` tools appear.
|
|
24
|
+
After pairing once: **CLI paired + the add-in open ≡ it just works**, against whatever environment (dev / prod / a client deployment) the add-in is wired to.
|
|
28
25
|
|
|
29
26
|
## Precondition gate (check this first, every time)
|
|
30
27
|
|
|
@@ -34,6 +31,8 @@ Authoring and rendering hard-require an **open PowerPoint host with the slidemin
|
|
|
34
31
|
2. One registered → that's your target `presentation_id`. **Several → ask the user which**, by `title`/`fileName` (never raw ids) — once for **which presentation's corporate design to download**, and again at render time for **which presentation to push into** (the CD source and the push target may be different presentations).
|
|
35
32
|
3. **None registered → stop.** Tell the user: *open PowerPoint, load the slidemind add-in, and pick or create a corporate design, then retry.*
|
|
36
33
|
|
|
34
|
+
> **Empty list vs. not paired — different fixes.** An *empty* registry means no PowerPoint is connected (step 3 → ask the user to open it / enable Connect CLI). A *not paired* error means this machine has no session — ask the user to pair once via the add-in's **"Connect CLI"** (see Bootstrap); don't ask them to open PowerPoint for that.
|
|
35
|
+
|
|
37
36
|
**Backgrounded PowerPoint is expected to work.** Do not pre-emptively ask the user to focus PowerPoint or the task pane. The bridge retains the registered presentation and pane-free routes keep working; live push/replace waits for the task-pane socket to reconnect if it truly dropped. Only if a command fails with *no host registered* (or a bridge wait timeout) do you ask the user to click the slidemind task pane once so it reconnects, then retry the same command — the deck and bundle are already prepared. Reserve the full "open PowerPoint…" instruction in (3) for when nothing is registered at all.
|
|
38
37
|
|
|
39
38
|
Local HTML preview (`slidemind build`) runs offline, but it reads a **previously materialized bundle** — so the host must have been available at least once.
|
package/src/bridge/bundle.mjs
CHANGED
|
@@ -358,16 +358,27 @@ export function loadEnginesForDeck(deckDir) {
|
|
|
358
358
|
return readEnginesFromDir(resolveBundleDirForDeck(deckDir));
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
361
|
+
// Image-producing engine kinds — the modality "image" plus the finer image
|
|
362
|
+
// content kinds an authored bundle may carry. Excludes "slide" (a slide-search
|
|
363
|
+
// engine returns whole slides, not insertable bytes). Mirrors the add-in's
|
|
364
|
+
// local-bridge `engine_not_image` gate so the CLI never picks an engine the
|
|
365
|
+
// bridge would reject.
|
|
366
|
+
const IMAGE_ENGINE_KIND_RE = /^(image|icon|photo|illustration|vector)$/i;
|
|
367
|
+
|
|
368
|
+
// True when an engine produces insertable image bytes. Gates on the engine's
|
|
369
|
+
// real `kind` (its modality from the design bundle) — NOT a guess over its
|
|
370
|
+
// label/description.
|
|
371
|
+
export function isImageEngine(engine) {
|
|
372
|
+
return IMAGE_ENGINE_KIND_RE.test(engine?.kind ?? "");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Pick the default engine to search: the first image engine, by its real
|
|
376
|
+
// `kind`. Returns null when the CD exposes no image engine (e.g. only a
|
|
377
|
+
// slide-search engine) — callers surface a "wire an image engine" message
|
|
378
|
+
// rather than falling back to an engine the bridge can't insert. Single source
|
|
379
|
+
// of truth for the heuristic shared by the CLI dispatcher (`slidemind search`)
|
|
380
|
+
// and the MCP `slidemind_search_engine` tool.
|
|
364
381
|
export function pickImageEngine(engines) {
|
|
365
382
|
const list = Array.isArray(engines) ? engines : [];
|
|
366
|
-
return (
|
|
367
|
-
list.find((e) =>
|
|
368
|
-
/image|icon/i.test(`${e.kind ?? ""} ${e.label ?? ""} ${e.description ?? ""}`),
|
|
369
|
-
) ||
|
|
370
|
-
list[0] ||
|
|
371
|
-
null
|
|
372
|
-
);
|
|
383
|
+
return list.find(isImageEngine) ?? null;
|
|
373
384
|
}
|
package/src/bridge/client.mjs
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
// Client for the slidemind
|
|
1
|
+
// Client for the slidemind cloud relay.
|
|
2
2
|
//
|
|
3
|
-
// The
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// The shared token is generated on add-in start and written to a state file.
|
|
9
|
-
// Reading it is sufficient auth; the add-in's middleware also rejects any
|
|
10
|
-
// non-loopback caller, so this is intentionally a same-machine-only surface.
|
|
3
|
+
// The CLI talks to `<backendURL>/api/cli/*` with a Bearer session token — both
|
|
4
|
+
// established once by `slidemind pair` (see config.mjs). The add-in's cloud
|
|
5
|
+
// backend runs render/bundle in-process and relays insert/replace/engine to the
|
|
6
|
+
// open PowerPoint pane. No localhost, no shared loopback token, no cert.
|
|
11
7
|
|
|
12
8
|
import fs from "node:fs";
|
|
13
9
|
import http from "node:http";
|
|
@@ -15,9 +11,11 @@ import os from "node:os";
|
|
|
15
11
|
import path from "node:path";
|
|
16
12
|
import https from "node:https";
|
|
17
13
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
import { readSessionConfig } from "./config.mjs";
|
|
15
|
+
|
|
16
|
+
function isLoopbackHostname(hostname) {
|
|
17
|
+
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
|
|
18
|
+
}
|
|
21
19
|
|
|
22
20
|
export function bridgeStateDir() {
|
|
23
21
|
if (process.platform === "win32") {
|
|
@@ -53,23 +51,26 @@ export function readBridgeToken() {
|
|
|
53
51
|
return token;
|
|
54
52
|
}
|
|
55
53
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
54
|
+
// The CLI talks to the cloud relay endpoint stored by `slidemind pair`. The
|
|
55
|
+
// session token is the credential — no localhost, no shared loopback token.
|
|
56
|
+
function requireSession() {
|
|
57
|
+
const session = readSessionConfig();
|
|
58
|
+
if (!session) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
'slidemind is not paired. In the slidemind add-in, click "Connect CLI" ' +
|
|
61
|
+
"and run the shown `slidemind pair …` command, then retry.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return session;
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
async function bridgeFetch(pathPart, init = {}) {
|
|
67
|
-
const
|
|
68
|
-
const
|
|
68
|
+
const session = requireSession();
|
|
69
|
+
const url = `${session.backendURL}${pathPart}`;
|
|
69
70
|
const headers = {
|
|
70
71
|
accept: "application/json",
|
|
71
72
|
...init.headers,
|
|
72
|
-
|
|
73
|
+
authorization: `Bearer ${session.sessionToken}`,
|
|
73
74
|
};
|
|
74
75
|
|
|
75
76
|
let response;
|
|
@@ -78,12 +79,18 @@ async function bridgeFetch(pathPart, init = {}) {
|
|
|
78
79
|
} catch (err) {
|
|
79
80
|
if (err && /ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(String(err.message || err))) {
|
|
80
81
|
throw new Error(
|
|
81
|
-
`slidemind
|
|
82
|
-
|
|
82
|
+
`slidemind relay unreachable at ${url}. Check your connection; ` +
|
|
83
|
+
"if it persists, re-pair with `slidemind pair`.",
|
|
83
84
|
);
|
|
84
85
|
}
|
|
85
86
|
throw err;
|
|
86
87
|
}
|
|
88
|
+
if (response.status === 401) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
"slidemind session is invalid or expired. Re-pair: open the add-in, " +
|
|
91
|
+
'click "Connect CLI", and run the shown `slidemind pair …`.',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
87
94
|
if (response.status < 200 || response.status >= 300) {
|
|
88
95
|
let parsed = null;
|
|
89
96
|
try {
|
|
@@ -135,7 +142,9 @@ function nodeRequest(urlString, init = {}) {
|
|
|
135
142
|
{
|
|
136
143
|
method: init.method ?? "GET",
|
|
137
144
|
headers,
|
|
138
|
-
|
|
145
|
+
// Validate the cloud's real cert; only skip for a loopback dev backend
|
|
146
|
+
// (self-signed) when pointing the CLI at a local add-in for development.
|
|
147
|
+
rejectUnauthorized: isHttps ? !isLoopbackHostname(url.hostname) : undefined,
|
|
139
148
|
},
|
|
140
149
|
(res) => {
|
|
141
150
|
const chunks = [];
|
|
@@ -167,7 +176,7 @@ function nodeRequest(urlString, init = {}) {
|
|
|
167
176
|
}
|
|
168
177
|
|
|
169
178
|
export async function listOpenPresentations() {
|
|
170
|
-
const res = await bridgeFetch("/
|
|
179
|
+
const res = await bridgeFetch("/api/cli/presentations");
|
|
171
180
|
const data = await res.json();
|
|
172
181
|
return Array.isArray(data?.presentations) ? data.presentations : [];
|
|
173
182
|
}
|
|
@@ -199,7 +208,7 @@ export async function requireHost() {
|
|
|
199
208
|
export async function getDesignBundle(presId) {
|
|
200
209
|
if (!presId) throw new Error("getDesignBundle: presId is required");
|
|
201
210
|
const res = await bridgeFetch(
|
|
202
|
-
`/
|
|
211
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/design-bundle`,
|
|
203
212
|
);
|
|
204
213
|
return res.json();
|
|
205
214
|
}
|
|
@@ -223,7 +232,7 @@ export async function renderPptx({
|
|
|
223
232
|
}
|
|
224
233
|
if (etag) body.etag = etag;
|
|
225
234
|
const res = await bridgeFetch(
|
|
226
|
-
`/
|
|
235
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/render-pptx`,
|
|
227
236
|
{
|
|
228
237
|
method: "POST",
|
|
229
238
|
headers: { "content-type": "application/json" },
|
|
@@ -241,7 +250,7 @@ export async function engineSearch({ presId, engineId, query }) {
|
|
|
241
250
|
if (!engineId) throw new Error("engineSearch: engineId is required");
|
|
242
251
|
if (!query) throw new Error("engineSearch: query is required");
|
|
243
252
|
const res = await bridgeFetch(
|
|
244
|
-
`/
|
|
253
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/engine/search`,
|
|
245
254
|
{
|
|
246
255
|
method: "POST",
|
|
247
256
|
headers: { "content-type": "application/json" },
|
|
@@ -270,7 +279,7 @@ export async function engineDownload({
|
|
|
270
279
|
const body = { searchId, selection };
|
|
271
280
|
if (iconColor) body.iconColor = iconColor;
|
|
272
281
|
const res = await bridgeFetch(
|
|
273
|
-
`/
|
|
282
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/engine/download`,
|
|
274
283
|
{
|
|
275
284
|
method: "POST",
|
|
276
285
|
headers: { "content-type": "application/json" },
|
|
@@ -291,7 +300,7 @@ export async function insertSlides({
|
|
|
291
300
|
if (!base64) throw new Error("insertSlides: base64 is required");
|
|
292
301
|
const body = { base64, insertAt, sourceSlideIds, operationId };
|
|
293
302
|
const res = await bridgeFetch(
|
|
294
|
-
`/
|
|
303
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/insert`,
|
|
295
304
|
{
|
|
296
305
|
method: "POST",
|
|
297
306
|
headers: { "content-type": "application/json" },
|
|
@@ -308,7 +317,7 @@ export async function replaceSlides({ presId, base64, operationId = null }) {
|
|
|
308
317
|
if (!presId) throw new Error("replaceSlides: presId is required");
|
|
309
318
|
if (!base64) throw new Error("replaceSlides: base64 is required");
|
|
310
319
|
const res = await bridgeFetch(
|
|
311
|
-
`/
|
|
320
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/replace`,
|
|
312
321
|
{
|
|
313
322
|
method: "POST",
|
|
314
323
|
headers: { "content-type": "application/json" },
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// CLI session config: where `slidemind pair` stores the cloud relay endpoint +
|
|
2
|
+
// the opaque session token it was issued. The CLI talks to <backendURL>/api/cli/*
|
|
3
|
+
// with `Authorization: Bearer <sessionToken>`; no localhost, no cert.
|
|
4
|
+
//
|
|
5
|
+
// Stored under XDG_CONFIG_HOME (config, not cache/state) so it persists and is
|
|
6
|
+
// clearly user-scoped. 0600 — it holds a bearer credential.
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
export function sessionConfigDir() {
|
|
13
|
+
if (process.platform === "win32") {
|
|
14
|
+
const base =
|
|
15
|
+
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
|
16
|
+
return path.join(base, "slidemind");
|
|
17
|
+
}
|
|
18
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
19
|
+
return path.join(base, "slidemind");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function sessionConfigPath() {
|
|
23
|
+
return path.join(sessionConfigDir(), "session.json");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Returns { backendURL, sessionToken } or null if not paired / unreadable.
|
|
27
|
+
export function readSessionConfig() {
|
|
28
|
+
let raw;
|
|
29
|
+
try {
|
|
30
|
+
raw = fs.readFileSync(sessionConfigPath(), "utf8");
|
|
31
|
+
} catch {
|
|
32
|
+
return null; // ENOENT or unreadable → treat as not paired
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const obj = JSON.parse(raw);
|
|
36
|
+
if (
|
|
37
|
+
obj &&
|
|
38
|
+
typeof obj.backendURL === "string" &&
|
|
39
|
+
typeof obj.sessionToken === "string" &&
|
|
40
|
+
obj.backendURL &&
|
|
41
|
+
obj.sessionToken
|
|
42
|
+
) {
|
|
43
|
+
return { backendURL: obj.backendURL, sessionToken: obj.sessionToken };
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
/* malformed JSON */
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function writeSessionConfig({ backendURL, sessionToken }) {
|
|
52
|
+
if (!backendURL || !sessionToken) {
|
|
53
|
+
throw new Error("writeSessionConfig: backendURL and sessionToken are required");
|
|
54
|
+
}
|
|
55
|
+
fs.mkdirSync(sessionConfigDir(), { recursive: true });
|
|
56
|
+
fs.writeFileSync(
|
|
57
|
+
sessionConfigPath(),
|
|
58
|
+
`${JSON.stringify({ backendURL, sessionToken }, null, 2)}\n`,
|
|
59
|
+
{ mode: 0o600 },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Remove the stored session. Returns true if a file was deleted.
|
|
64
|
+
export function clearSessionConfig() {
|
|
65
|
+
try {
|
|
66
|
+
fs.rmSync(sessionConfigPath());
|
|
67
|
+
return true;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -44,6 +44,26 @@ function firstExisting(candidates) {
|
|
|
44
44
|
return null;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// Does a design root actually carry brand files? A root qualifies if it has the
|
|
48
|
+
// corporate-design CSS or a layouts dir that holds at least one real layout
|
|
49
|
+
// (a `*.jsx` or `layouts.css`). A bare/junk-only `layouts/` — e.g. one left
|
|
50
|
+
// behind on disk holding only a macOS `.DS_Store` — must NOT count, or it would
|
|
51
|
+
// mask the "materialize the bundle first" guidance for a deck that hasn't
|
|
52
|
+
// downloaded one yet.
|
|
53
|
+
function carriesDesign(root, cssName) {
|
|
54
|
+
if (!root) return false;
|
|
55
|
+
if (fs.existsSync(path.join(root, cssName))) return true;
|
|
56
|
+
const layoutsDir = path.join(root, "layouts");
|
|
57
|
+
if (!fs.existsSync(layoutsDir)) return false;
|
|
58
|
+
try {
|
|
59
|
+
return fs
|
|
60
|
+
.readdirSync(layoutsDir)
|
|
61
|
+
.some((f) => f.endsWith(".jsx") || f === "layouts.css");
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
47
67
|
// First existing candidate, or `fallback` when none exist. Encodes the
|
|
48
68
|
// deck-override → bundle → skill precedence in one place so every resolved path
|
|
49
69
|
// shares the same lookup rule.
|
|
@@ -73,12 +93,7 @@ export function resolveSkill(skillName, planMeta = {}, opts = {}) {
|
|
|
73
93
|
const designRoots = [
|
|
74
94
|
bundleDir,
|
|
75
95
|
fs.existsSync(skillDir) ? skillDir : null,
|
|
76
|
-
].filter(
|
|
77
|
-
(r) =>
|
|
78
|
-
r &&
|
|
79
|
-
(fs.existsSync(path.join(r, cssName)) ||
|
|
80
|
-
fs.existsSync(path.join(r, "layouts"))),
|
|
81
|
-
);
|
|
96
|
+
].filter((r) => carriesDesign(r, cssName));
|
|
82
97
|
if (designRoots.length === 0) {
|
|
83
98
|
throw new Error(
|
|
84
99
|
`No design source for skill "${skillName}". The corporate design now lives ` +
|