slidemind 0.3.12 → 0.3.14

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.
Files changed (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +86 -21
  4. package/README.md +14 -17
  5. package/bin/slidemind.mjs +65 -4969
  6. package/package.json +1 -1
  7. package/skills/create-slides/SKILL.md +113 -74
  8. package/skills/search/SKILL.md +25 -7
  9. package/src/bridge/build-and-push.mjs +108 -15
  10. package/src/bridge/bundle.mjs +69 -0
  11. package/src/bridge/capture.mjs +71 -8
  12. package/src/bridge/client/brands.mjs +622 -0
  13. package/src/bridge/client/engine-routing.mjs +237 -0
  14. package/src/bridge/client/engine-search.mjs +310 -0
  15. package/src/bridge/client/engine-slides.mjs +435 -0
  16. package/src/bridge/client/pane-ops.mjs +335 -0
  17. package/src/bridge/client/presentations.mjs +587 -0
  18. package/src/bridge/client/render.mjs +317 -0
  19. package/src/bridge/client/transport.mjs +695 -0
  20. package/src/bridge/client.mjs +80 -3452
  21. package/src/bridge/config.mjs +1 -1
  22. package/src/bridge/engine-download.mjs +15 -2
  23. package/src/builders/build.mjs +16 -669
  24. package/src/builders/draft-check.mjs +376 -0
  25. package/src/builders/lint.mjs +38 -2
  26. package/src/builders/preview.html +799 -0
  27. package/src/builders/review.mjs +23 -4
  28. package/src/builders/visual-families.mjs +6 -8
  29. package/src/cli/common.mjs +179 -0
  30. package/src/cli/concepts.mjs +310 -0
  31. package/src/cli/connection.mjs +147 -0
  32. package/src/cli/deck.mjs +608 -0
  33. package/src/cli/designs.mjs +113 -0
  34. package/src/cli/diagnose.mjs +141 -0
  35. package/src/cli/download-bundle.mjs +144 -0
  36. package/src/cli/engine-helpers.mjs +429 -0
  37. package/src/cli/engine-results.mjs +320 -0
  38. package/src/cli/help.mjs +201 -0
  39. package/src/cli/install.mjs +257 -0
  40. package/src/cli/read.mjs +120 -0
  41. package/src/cli/ready.mjs +805 -0
  42. package/src/cli/search.mjs +608 -0
  43. package/src/cli/session.mjs +177 -0
  44. package/src/cli/setup.mjs +282 -0
  45. package/src/cli/slide-reuse.mjs +334 -0
  46. package/src/cli/status.mjs +118 -0
  47. package/src/cli/workspace.mjs +302 -0
  48. package/src/installers/codex.mjs +1 -1
  49. package/src/update-check.mjs +231 -0
@@ -0,0 +1,302 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { fail, requireReviewApproved, LOCAL_PPTX_RE, resolveDecksDir, resolveDeckArg, splitDeckArg, renderProgressPrinter, freshenDeckBundle, loadBridgeModule, SRC_DIR } from "./common.mjs";
5
+ import { readDesignName } from "./designs.mjs";
6
+
7
+ // `slidemind adopt <file.pptx> [--deck <slug>] [--brand <presFP>] [--no-convert]`
8
+ // — reconstruct a local deck workspace from a presentation's deck-embedded
9
+ // source tags (slides authored with slidemind carry their jsx inside the file).
10
+ // Slides with no source are handed to the add-in's deterministic PPTX→JSX
11
+ // conversion route where it is available (`docs/slidemind-cli-connect/
12
+ // 14-foreign-slide-conversion.md`); whatever it declines folds in as native
13
+ // reused .pptx. See also intern-slidemind docs/deck-embedded-source-spec.md.
14
+ export async function cmdAdopt(rest) {
15
+ const args = [...rest];
16
+ let deckSlug = null;
17
+ let brand = null;
18
+ let convert = true;
19
+ const positional = [];
20
+ for (let i = 0; i < args.length; i++) {
21
+ const a = args[i];
22
+ if (a === "--deck") {
23
+ deckSlug = args[++i] ?? null;
24
+ if (!deckSlug) fail("--deck expects a deck slug");
25
+ } else if (a === "--brand") {
26
+ brand = args[++i] ?? null;
27
+ if (!brand) fail("--brand expects a design id (presFP)");
28
+ } else if (a === "--no-convert") {
29
+ convert = false;
30
+ } else if (a.startsWith("-")) {
31
+ fail(`unknown flag: ${a}`);
32
+ } else {
33
+ positional.push(a);
34
+ }
35
+ }
36
+ const pptxArg = positional[0];
37
+ if (!pptxArg) {
38
+ fail("usage: slidemind adopt <file.pptx> [--deck <slug>] [--brand <presFP>] [--no-convert]");
39
+ }
40
+ // v1 is FILE decks only. A presId (or any non-file argument) would mean
41
+ // converting a LIVE presentation, which needs slide-export and tag-read
42
+ // events the relay does not have — say so instead of "no such file".
43
+ const {
44
+ LIVE_DECK_UNSUPPORTED,
45
+ adoptDeckFromPptx,
46
+ convertUntaggedSlides,
47
+ readSourceDeck,
48
+ } = await import(pathToFileURL(path.join(SRC_DIR, "lib", "adopt.mjs")).href);
49
+ if (!LOCAL_PPTX_RE.test(pptxArg)) fail(LIVE_DECK_UNSUPPORTED);
50
+ const pptxPath = path.resolve(pptxArg);
51
+ if (!fs.existsSync(pptxPath)) fail(`no such file: ${pptxArg}`);
52
+ const slug = deckSlug ?? path.basename(pptxPath).replace(/\.pptx$/i, "");
53
+ // The decks dir, not a hardcoded ./decks — otherwise `adopt` writes where no
54
+ // other subcommand looks whenever SLIDEMIND_DECKS is set. A RELATIVE slug
55
+ // must stay inside it (an explicit absolute path is the user's own choice) —
56
+ // every other untrusted name in this feature is escape-guarded, and this one
57
+ // rides agent-authored command lines.
58
+ const decksRoot = path.resolve(resolveDecksDir());
59
+ const deckDir = path.isAbsolute(slug) ? slug : path.resolve(decksRoot, slug);
60
+ if (
61
+ !path.isAbsolute(slug) &&
62
+ (deckDir === decksRoot || !deckDir.startsWith(decksRoot + path.sep))
63
+ ) {
64
+ fail(
65
+ `--deck '${slug}' escapes the decks directory (${decksRoot}) — ` +
66
+ "pass an absolute path to adopt somewhere else on purpose",
67
+ );
68
+ }
69
+ // ONE read/unzip/tag-decode of the source file, threaded into both halves —
70
+ // they must see the same snapshot (and a 40 MB deck decodes slowly).
71
+ const source = readSourceDeck(pptxPath);
72
+ const converted = convert
73
+ ? await convertUntaggedSlides({
74
+ pptxPath,
75
+ deckDir,
76
+ brandId: brand,
77
+ note: (line) => process.stderr.write(`${line}\n`),
78
+ source,
79
+ })
80
+ : null;
81
+ const result = adoptDeckFromPptx({ pptxPath, deckDir, converted, source });
82
+ // A decks dir outside the cwd (SLIDEMIND_DECKS) relativizes to ../../.. noise
83
+ // — print the absolute path there instead.
84
+ const shown = path.relative(process.cwd(), deckDir);
85
+ process.stdout.write(
86
+ `Adopted ${result.adopted.length} slide(s) with embedded source` +
87
+ (result.converted.length
88
+ ? `, converted ${result.converted.length} foreign slide(s) to editable jsx`
89
+ : "") +
90
+ (result.native.length
91
+ ? ` and folded ${result.native.length} untagged slide(s) as native .pptx`
92
+ : "") +
93
+ ` into ${shown.startsWith("..") ? deckDir : shown}/.\n` +
94
+ (result.skipped.length
95
+ ? `${result.skipped.length} slide(s) stayed native: ` +
96
+ `${result.skipped
97
+ .map((s) => `#${s.index ?? "?"} ${s.reason ?? "unknown"}`)
98
+ .join(", ")}\n`
99
+ : "") +
100
+ (result.bundle?.presFP && result.bundle.presFP !== "unknown"
101
+ ? `Deck bound to bundle ${result.bundle.presFP}.\n`
102
+ : `No bundle reference found in the file — run 'slidemind download-bundle ${slug}' before rendering.\n`) +
103
+ `Edit slides/*.jsx and run 'slidemind push ${slug}' or 'slidemind build-pptx ${slug}'.\n`,
104
+ );
105
+ }
106
+
107
+ export async function cmdPush(rest) {
108
+ const { deck, passthrough } = splitDeckArg(rest, "push");
109
+ let to = null;
110
+ let at = null;
111
+ let replace = false;
112
+ let slides = null;
113
+ let acceptConversions = false;
114
+ let force = false;
115
+ for (let i = 0; i < passthrough.length; i++) {
116
+ const a = passthrough[i];
117
+ if (a === "--to") {
118
+ to = passthrough[++i];
119
+ if (!to) fail("--to expects a presId");
120
+ } else if (a === "--at") {
121
+ const next = passthrough[++i];
122
+ const n = parseInt(next, 10);
123
+ if (!Number.isFinite(n) || n < 0) {
124
+ fail(`--at expects a non-negative integer, got '${next}'`);
125
+ }
126
+ at = n;
127
+ } else if (a === "--replace") {
128
+ replace = true;
129
+ } else if (a === "--accept-conversions") {
130
+ acceptConversions = true;
131
+ } else if (a === "--force") {
132
+ force = true;
133
+ } else if (a === "--slides") {
134
+ const next = passthrough[++i];
135
+ if (!next) fail("--slides expects a comma-separated list of slide ids");
136
+ slides = next.split(",").map((s) => s.trim()).filter(Boolean);
137
+ } else if (a.startsWith("-")) {
138
+ fail(`unknown flag: ${a}`);
139
+ } else {
140
+ fail(`unexpected argument: ${a}`);
141
+ }
142
+ }
143
+ if (replace && (at !== null || slides)) {
144
+ fail("--replace cannot be combined with --at or --slides");
145
+ }
146
+
147
+ const deckDir = resolveDeckArg(deck);
148
+ if (!fs.existsSync(path.join(deckDir, "slides"))) {
149
+ fail(`expected 'slides/' folder in ${deckDir}`);
150
+ }
151
+ await requireReviewApproved(deckDir);
152
+ await freshenDeckBundle(deckDir);
153
+
154
+ const mod = await import(
155
+ pathToFileURL(path.join(SRC_DIR, "bridge", "build-and-push.mjs")).href
156
+ );
157
+ const result = await mod.buildAndPush({
158
+ deckDir,
159
+ presentationId: to,
160
+ insertAt: at,
161
+ replace,
162
+ slideIds: slides,
163
+ acceptConversions,
164
+ force,
165
+ onProgress: renderProgressPrinter(),
166
+ });
167
+ for (const w of result.warnings ?? []) {
168
+ process.stderr.write(` warning: ${w}\n`);
169
+ }
170
+ for (const s of result.skippedSlides ?? []) {
171
+ process.stderr.write(` skipped ${s.stem}: ${s.reason}\n`);
172
+ }
173
+ if (result.paneSuspended) {
174
+ process.stdout.write(
175
+ `The deck rendered, but no PowerPoint window was ready to receive it.\n` +
176
+ `Open PowerPoint with the slidemind pane (or click the pane once to wake it), ` +
177
+ `then re-run: slidemind push ${deck}\n` +
178
+ `The rendered deck was saved to ${path.relative(process.cwd(), result.fallbackFile)} in the meantime.\n`,
179
+ );
180
+ return;
181
+ }
182
+ process.stdout.write(
183
+ `Pushed ${result.bytes} bytes (${result.mode}) to presentation ${result.presId}\n`,
184
+ );
185
+ }
186
+
187
+ export async function cmdNew(rest) {
188
+ let to = null;
189
+ let brand = null;
190
+ let noBind = false;
191
+ const positional = [];
192
+ for (let i = 0; i < rest.length; i++) {
193
+ const a = rest[i];
194
+ if (a === "--to") {
195
+ to = rest[++i];
196
+ if (!to) fail("--to expects a presId");
197
+ } else if (a === "--brand") {
198
+ brand = rest[++i];
199
+ if (!brand) fail("--brand expects a design id (presFP)");
200
+ } else if (a === "--no-bind") {
201
+ noBind = true;
202
+ } else if (a.startsWith("-")) {
203
+ fail(`unknown flag: ${a}`);
204
+ } else {
205
+ positional.push(a);
206
+ }
207
+ }
208
+ if (brand && to) {
209
+ fail("--brand and --to are mutually exclusive (a design id or a window, not both)");
210
+ }
211
+ const name = positional[0];
212
+ if (!name) fail("usage: slidemind new <name> [--brand <presFP> | --to <presId>] [--no-bind]");
213
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(name)) {
214
+ fail(`deck name must be kebab-case alphanumerics, got '${name}'`);
215
+ }
216
+ const decksDir = resolveDecksDir();
217
+ const deckDir = path.join(decksDir, name);
218
+ if (fs.existsSync(deckDir)) {
219
+ fail(`deck already exists: ${deckDir}`);
220
+ }
221
+ fs.mkdirSync(path.join(deckDir, "slides"), { recursive: true });
222
+ fs.writeFileSync(
223
+ path.join(deckDir, "plan.md"),
224
+ `# ${name}\n\n` +
225
+ `Ask your agent (Claude Code / Codex / opencode) to plan and author this deck —\n` +
226
+ `the slidemind-create-slides skill fills this in, then writes slides under ./slides/\n` +
227
+ `as NN-<topic>.jsx (zero-padded prefix — slide order is filename order).\n`,
228
+ );
229
+ process.stdout.write(`Created ${path.relative(process.cwd(), deckDir)}\n`);
230
+ process.stdout.write(
231
+ `Next: ask your agent to author "${name}", then \`slidemind view-draft ${name}\`\n`,
232
+ );
233
+
234
+ // Stateless auto-bind: bind the fresh deck to whatever design is OPEN so the
235
+ // agent skips a follow-up `download-bundle`. Best-effort — a bind miss (no
236
+ // host, ambiguous designs, bundle not yet materialized, any network error)
237
+ // NEVER fails the scaffold; it just drops a soft hint. Fully offline when
238
+ // unpaired: no network attempted, no hint. `--no-bind` opts out entirely.
239
+ if (noBind) return;
240
+ const cfg = await loadBridgeModule("config");
241
+ if (cfg.readSessions().length === 0) return;
242
+ let boundMsg = null;
243
+ // --brand: the user picked a design (the Clarify question) — bind to THAT,
244
+ // never to whatever window happens to be open. Cache-only, so it needs the
245
+ // bundle `ready` already materialized; a miss names the command that does.
246
+ if (brand) {
247
+ const bundle = await loadBridgeModule("bundle");
248
+ const meta = bundle.readMaterializedBundleMeta(brand);
249
+ if (!meta?.presFP) {
250
+ process.stdout.write(
251
+ `not bound — design '${brand}' is not materialized here; run \`slidemind ready ${name} --brand ${brand}\`\n`,
252
+ );
253
+ return;
254
+ }
255
+ bundle.writeDeckBundleRef(deckDir, { presFP: meta.presFP, etag: meta.etag ?? null });
256
+ const label = readDesignName(bundle.bundleDirForPresFP(meta.presFP)) ?? meta.presFP;
257
+ process.stdout.write(`bound to design ${label}\n`);
258
+ return;
259
+ }
260
+ try {
261
+ const client = await import(
262
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
263
+ );
264
+ const bundle = await import(
265
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
266
+ );
267
+ const { presentations } = await client.listOpenPresentationsDetailed();
268
+ const resolution = client.resolveDesignSource(presentations, {
269
+ explicitPresId: to,
270
+ });
271
+ // The same order as ready: the one design open, else the one design
272
+ // cached here. (No liveness ping — a zombie's false ambiguity just leaves
273
+ // the deck unbound, and `ready <name>` prunes it.)
274
+ const cached = bundle.listCachedBundles();
275
+ const presFP =
276
+ resolution.status === "resolved"
277
+ ? resolution.entry.brandId
278
+ : resolution.status === "none" && !to && cached.length === 1
279
+ ? cached[0].presFP
280
+ : null;
281
+ if (presFP) {
282
+ const meta = bundle.readMaterializedBundleMeta(presFP);
283
+ if (meta?.presFP) {
284
+ bundle.writeDeckBundleRef(deckDir, {
285
+ presFP: meta.presFP,
286
+ etag: meta.etag ?? null,
287
+ });
288
+ const label =
289
+ readDesignName(bundle.bundleDirForPresFP(meta.presFP)) ?? meta.presFP;
290
+ boundMsg = `bound to design ${label}`;
291
+ }
292
+ }
293
+ } catch {
294
+ /* best-effort — fall through to the soft hint */
295
+ }
296
+ process.stdout.write(
297
+ boundMsg
298
+ ? `${boundMsg}\n`
299
+ : `UNBOUND — ${name} has no corporate design yet, so every render would need an open window. ` +
300
+ `Bind it: \`slidemind ready ${name}\` (one design at hand) or \`slidemind ready ${name} --brand <presFP>\` (pick from \`slidemind ls designs\`).\n`,
301
+ );
302
+ }
@@ -160,7 +160,7 @@ function writeAgentsBlock() {
160
160
  `Both drive the slidemind PowerPoint add-in and read every brand rule — tokens,\n` +
161
161
  `layout palette, brand imagery — from the design bundle the add-in serves.\n` +
162
162
  `\n` +
163
- `Build with: \`slidemind build <deck>\` (HTML) or \`slidemind build-pptx <deck>\` (PPTX).\n` +
163
+ `Build with: \`slidemind view-draft <deck>\` (draft PNGs) or \`slidemind build-pptx <deck>\` (PPTX).\n` +
164
164
  `${AGENTS_MARK_END}\n`;
165
165
 
166
166
  const file = agentsFile();
@@ -0,0 +1,231 @@
1
+ // "Your slidemind is out of date" — the nudge, and the background self-update.
2
+ //
3
+ // The add-in's version floor (`x-slidemind-min-cli`) only fires once a CLI is
4
+ // too old for the backend to talk to it at all: by then the user is hard
5
+ // blocked. This is the softer, earlier signal — a plain npm dist-tag check
6
+ // against the registry, so a user or an agent learns about a release the day it
7
+ // lands instead of on the next breaking floor bump.
8
+ //
9
+ // Cost control: the registry is contacted at most once per interval (default
10
+ // 6h) per config dir; every other run reads the cached answer off disk (a few
11
+ // hundred microseconds). The check runs AFTER the command has finished, so it
12
+ // never delays real work and a background `npm install -g` can never swap the
13
+ // package directory out from under a running command.
14
+
15
+ import { spawn } from "node:child_process";
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ import { compareCliVersions } from "./bridge/client.mjs";
21
+ import { sessionConfigDir } from "./bridge/config.mjs";
22
+ import { isClaudePluginInstall, updateCliInstruction } from "./install-channel.mjs";
23
+
24
+ const PACKAGE_ROOT = path.resolve(
25
+ path.dirname(fileURLToPath(import.meta.url)),
26
+ "..",
27
+ );
28
+
29
+ // Commands that either ARE the update, or are about to install a fresh copy
30
+ // anyway. Nudging inside them is noise at best and contradictory at worst.
31
+ const SKIP_COMMANDS = new Set([
32
+ "update",
33
+ "upgrade",
34
+ "self-update",
35
+ "setup",
36
+ "init",
37
+ "install",
38
+ "uninstall",
39
+ ]);
40
+
41
+ function intervalMs() {
42
+ return (
43
+ parseInt(process.env.SLIDEMIND_UPDATE_CHECK_INTERVAL_MS || "", 10) ||
44
+ 6 * 60 * 60 * 1000
45
+ );
46
+ }
47
+
48
+ function registryUrl() {
49
+ return (
50
+ process.env.SLIDEMIND_REGISTRY_URL ||
51
+ "https://registry.npmjs.org/slidemind/latest"
52
+ );
53
+ }
54
+
55
+ function cachePath() {
56
+ return path.join(sessionConfigDir(), "update-check.json");
57
+ }
58
+
59
+ function readCache() {
60
+ try {
61
+ const raw = JSON.parse(fs.readFileSync(cachePath(), "utf8"));
62
+ return raw && typeof raw === "object" ? raw : {};
63
+ } catch {
64
+ return {};
65
+ }
66
+ }
67
+
68
+ function writeCache(next) {
69
+ try {
70
+ fs.mkdirSync(sessionConfigDir(), { recursive: true });
71
+ fs.writeFileSync(cachePath(), JSON.stringify(next, null, 2) + "\n");
72
+ } catch {
73
+ // A read-only or unwritable config dir just means we re-check next run.
74
+ }
75
+ }
76
+
77
+ // Resolve the `npm` that sits next to the node running this process, so an
78
+ // update lands the global into the same prefix slidemind was launched from.
79
+ export function npmBin() {
80
+ const dir = path.dirname(process.execPath);
81
+ const candidates =
82
+ process.platform === "win32"
83
+ ? [path.join(dir, "npm.cmd"), path.join(dir, "npm")]
84
+ : [path.join(dir, "npm")];
85
+ for (const c of candidates) {
86
+ if (fs.existsSync(c)) return c;
87
+ }
88
+ return "npm"; // PATH fallback
89
+ }
90
+
91
+ // npx runs the package straight out of its own cache and is expected to be
92
+ // throwaway — a global install from inside one would surprise the user and
93
+ // would not change what the next `npx slidemind@latest` runs.
94
+ function isNpxRun(root = PACKAGE_ROOT) {
95
+ return path.resolve(root).split(/[\\/]+/).includes("_npx");
96
+ }
97
+
98
+ // Who can actually replace THIS binary?
99
+ // npm — a background `npm install -g slidemind@latest` does it
100
+ // plugin — only `/plugin`; a global install lands a second, unused copy
101
+ // npx — nobody; the next npx invocation resolves its own version
102
+ // dev — a checkout (`npm link`): an install would REPLACE the developer's
103
+ // symlink with the registry copy, so this channel is never nudged
104
+ export function updateChannel() {
105
+ if (fs.existsSync(path.join(PACKAGE_ROOT, ".git"))) return "dev";
106
+ if (isClaudePluginInstall()) return "plugin";
107
+ if (isNpxRun()) return "npx";
108
+ return "npm";
109
+ }
110
+
111
+ function autoUpdateEnabled(channel) {
112
+ if (channel !== "npm") return false;
113
+ if (process.env.SLIDEMIND_NO_AUTO_UPDATE) return false;
114
+ if (process.env.CI) return false;
115
+ return true;
116
+ }
117
+
118
+ // The message, as one pure function — everything above it decides WHETHER to
119
+ // call it, this decides WHAT it says. `auto` is what we actually kicked off.
120
+ export function updateNotice({ current, latest, channel, auto, stalled }) {
121
+ if (!latest || compareCliVersions(current, latest) >= 0) return null;
122
+ const head = `slidemind ${current} is out of date — ${latest} is on npm.`;
123
+ if (stalled) {
124
+ return (
125
+ `${head} A background auto-update to ${latest} was started earlier but this ` +
126
+ `binary is still ${current} — ${updateCliInstruction()}`
127
+ );
128
+ }
129
+ if (auto) {
130
+ return `${head} Updating in the background — the next \`slidemind\` run uses ${latest}.`;
131
+ }
132
+ return `${head} ${updateCliInstruction()}`;
133
+ }
134
+
135
+ async function fetchLatestVersion() {
136
+ try {
137
+ const res = await fetch(registryUrl(), {
138
+ headers: { accept: "application/json" },
139
+ signal: AbortSignal.timeout(
140
+ parseInt(process.env.SLIDEMIND_UPDATE_CHECK_TIMEOUT_MS || "", 10) || 2000,
141
+ ),
142
+ });
143
+ if (!res.ok) return null;
144
+ const body = await res.json();
145
+ return typeof body?.version === "string" ? body.version : null;
146
+ } catch {
147
+ return null; // offline, blocked, slow — never a reason to bother the user
148
+ }
149
+ }
150
+
151
+ function startBackgroundUpdate(latest) {
152
+ const logPath = path.join(sessionConfigDir(), "update.log");
153
+ let out = "ignore";
154
+ try {
155
+ fs.mkdirSync(sessionConfigDir(), { recursive: true });
156
+ out = fs.openSync(logPath, "a");
157
+ fs.writeSync(out, `\n[${new Date().toISOString()}] auto-update -> ${latest}\n`);
158
+ } catch {
159
+ out = "ignore";
160
+ }
161
+ try {
162
+ const child = spawn(
163
+ npmBin(),
164
+ ["install", "-g", "--loglevel=error", "slidemind@latest"],
165
+ { detached: true, stdio: ["ignore", out, out] },
166
+ );
167
+ child.unref();
168
+ return true;
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
174
+ // Called once, after the command has run. Never throws, never blocks longer
175
+ // than the fetch timeout, and writes only to stderr so `--json` stdout payloads
176
+ // stay clean.
177
+ //
178
+ // `force` is set when the command just hit the add-in's version floor
179
+ // (`cli_update_required`): the cached registry answer may predate the release
180
+ // that raised the floor, so re-fetch now and re-attempt the auto-update even
181
+ // if one was already tried for this version.
182
+ export async function announceUpdate({ command, current, force = false }) {
183
+ try {
184
+ if (process.env.SLIDEMIND_NO_UPDATE_CHECK) return;
185
+ if (!command || SKIP_COMMANDS.has(command)) return;
186
+
187
+ const channel = updateChannel();
188
+ // A checkout is never auto-updated or nudged (it would clobber `npm link`);
189
+ // SLIDEMIND_UPDATE_CHECK=1 forces the path on so the suite can drive it.
190
+ if (channel === "dev" && !process.env.SLIDEMIND_UPDATE_CHECK) return;
191
+
192
+ const cache = readCache();
193
+ let latest = typeof cache.latest === "string" ? cache.latest : null;
194
+ if (force || Date.now() - (Number(cache.checkedAt) || 0) > intervalMs()) {
195
+ const fetched = await fetchLatestVersion();
196
+ latest = fetched ?? latest;
197
+ writeCache({ ...cache, checkedAt: Date.now(), latest });
198
+ }
199
+ if (!latest || compareCliVersions(current, latest) >= 0) return;
200
+
201
+ // One auto-update attempt per released version per interval: if the install
202
+ // failed (a root-owned npm prefix is the usual reason) we must not respawn
203
+ // it on every single command — we say so instead.
204
+ const prior = cache.autoUpdate;
205
+ const attempted =
206
+ !force &&
207
+ prior?.version === latest &&
208
+ Date.now() - (Number(prior.at) || 0) < intervalMs();
209
+ let auto = false;
210
+ if (autoUpdateEnabled(channel) && !attempted) {
211
+ auto = startBackgroundUpdate(latest);
212
+ if (auto) {
213
+ writeCache({
214
+ ...readCache(),
215
+ autoUpdate: { version: latest, at: Date.now() },
216
+ });
217
+ }
218
+ }
219
+
220
+ const notice = updateNotice({
221
+ current,
222
+ latest,
223
+ channel,
224
+ auto,
225
+ stalled: attempted,
226
+ });
227
+ if (notice) process.stderr.write(`slidemind: ${notice}\n`);
228
+ } catch {
229
+ // An update nudge must never be the thing that fails a command.
230
+ }
231
+ }