promptslide 0.3.15 → 0.3.16

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.16](https://github.com/prompticeu/promptslide/compare/promptslide-v0.3.15...promptslide-v0.3.16) (2026-08-28)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **cli:** guard deck sync against stale slides ([#103](https://github.com/prompticeu/promptslide/issues/103)) ([f8dddc9](https://github.com/prompticeu/promptslide/commit/f8dddc9fe4177c37dbae729d1efc8971aa46ed92))
9
+
3
10
  ## [0.3.15](https://github.com/prompticeu/promptslide/compare/promptslide-v0.3.14...promptslide-v0.3.15) (2026-04-09)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptslide",
3
- "version": "0.3.15",
3
+ "version": "0.3.16",
4
4
  "description": "CLI and slide engine for PromptSlide presentations",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -24,6 +24,7 @@
24
24
  ],
25
25
  "scripts": {
26
26
  "build": "tsup",
27
+ "test": "node --test test/*.test.mjs",
27
28
  "prepublishOnly": "npm run build"
28
29
  },
29
30
  "license": "MIT",
@@ -21,6 +21,7 @@ import {
21
21
  addSlideToDeckConfig,
22
22
  replaceDeckConfig
23
23
  } from "../utils/deck-config.mjs"
24
+ import { collectRegistrySlideSlugs, partitionDeckSlides } from "../utils/deck-sync.mjs"
24
25
  import { confirm, closePrompts } from "../utils/prompts.mjs"
25
26
 
26
27
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -164,9 +165,15 @@ export async function add(args) {
164
165
  }
165
166
  }
166
167
  } else if (item.type === "deck" && item.meta?.slides) {
168
+ const registrySlideSlugs = collectRegistrySlideSlugs(resolved.items)
169
+ const { available, missing } = partitionDeckSlides(item.meta.slides, registrySlideSlugs)
170
+ if (missing.length > 0) {
171
+ console.log(` ${yellow("⚠")} Skipped ${missing.length} slide(s) listed in the deck but not included in this version:`)
172
+ console.log(` ${dim(missing.map(s => s.slug).join(", "))}`)
173
+ }
167
174
  const shouldReplace = await confirm(" Replace entire deck-config.ts with this deck?", true)
168
175
  if (shouldReplace) {
169
- const slides = item.meta.slides.map(s => ({
176
+ const slides = available.map(s => ({
170
177
  componentName: s.componentName || toPascalCase(s.slug),
171
178
  importPath: `@/slides/${s.slug}`,
172
179
  steps: s.steps,
@@ -179,7 +186,7 @@ export async function add(args) {
179
186
  console.log(` ${green("✓")} Replaced ${cyan("deck-config.ts")} ${dim(`(${slides.length} slides)`)}`)
180
187
  } else {
181
188
  // Append individual slides
182
- for (const s of item.meta.slides) {
189
+ for (const s of available) {
183
190
  const componentName = s.componentName || toPascalCase(s.slug)
184
191
  const importPath = `@/slides/${s.slug}`
185
192
  const updated = addSlideToDeckConfig(cwd, { componentName, importPath, steps: s.steps })
@@ -8,6 +8,7 @@ import { hexToOklch } from "../utils/colors.mjs"
8
8
  import { closePrompts } from "../utils/prompts.mjs"
9
9
  import { fetchRegistryItem, resolveRegistryDependencies, writeLockfile } from "../utils/registry.mjs"
10
10
  import { toPascalCase, replaceDeckConfig } from "../utils/deck-config.mjs"
11
+ import { collectRegistrySlideSlugs, partitionDeckSlides } from "../utils/deck-sync.mjs"
11
12
  import { ensureTsConfig } from "../utils/tsconfig.mjs"
12
13
 
13
14
  const __filename = fileURLToPath(import.meta.url)
@@ -158,7 +159,13 @@ export async function clone(args) {
158
159
 
159
160
  // 6. Generate deck-config.ts
160
161
  if (item.meta?.slides) {
161
- const slides = item.meta.slides.map(s => ({
162
+ const registrySlideSlugs = collectRegistrySlideSlugs(resolved.items)
163
+ const { available, missing } = partitionDeckSlides(item.meta.slides, registrySlideSlugs)
164
+ if (missing.length > 0) {
165
+ console.log(` ${yellow("!")} Skipped ${missing.length} slide(s) listed in the deck but not included in this version:`)
166
+ console.log(` ${dim(missing.map(s => s.slug).join(", "))}`)
167
+ }
168
+ const slides = available.map(s => ({
162
169
  componentName: s.componentName || toPascalCase(s.slug),
163
170
  importPath: `@/slides/${s.slug}`,
164
171
  steps: s.steps,
@@ -9,6 +9,7 @@ import { hexToOklch, isValidHex } from "../utils/colors.mjs"
9
9
  import { prompt, confirm, closePrompts } from "../utils/prompts.mjs"
10
10
  import { fetchRegistryItem, resolveRegistryDependencies, writeLockfile } from "../utils/registry.mjs"
11
11
  import { toPascalCase, replaceDeckConfig } from "../utils/deck-config.mjs"
12
+ import { collectRegistrySlideSlugs, partitionDeckSlides } from "../utils/deck-sync.mjs"
12
13
  import { ensureTsConfig } from "../utils/tsconfig.mjs"
13
14
 
14
15
  const __filename = fileURLToPath(import.meta.url)
@@ -221,7 +222,13 @@ export async function create(args) {
221
222
 
222
223
  // Reconstruct deck-config.ts from deckConfig metadata
223
224
  if (fromItem.meta?.slides) {
224
- const slides = fromItem.meta.slides.map(s => ({
225
+ const registrySlideSlugs = collectRegistrySlideSlugs(resolved.items)
226
+ const { available, missing } = partitionDeckSlides(fromItem.meta.slides, registrySlideSlugs)
227
+ if (missing.length > 0) {
228
+ console.log(` ${yellow("⚠")} Skipped ${missing.length} slide(s) listed in the deck but not included in this version:`)
229
+ console.log(` ${dim(missing.map(s => s.slug).join(", "))}`)
230
+ }
231
+ const slides = available.map(s => ({
225
232
  componentName: s.componentName || toPascalCase(s.slug),
226
233
  importPath: `@/slides/${s.slug}`,
227
234
  steps: s.steps,
@@ -5,9 +5,10 @@ import { fileURLToPath } from "node:url"
5
5
  import { bold, green, cyan, red, yellow, dim } from "../utils/ansi.mjs"
6
6
  import { requireAuth } from "../utils/auth.mjs"
7
7
  import { captureSlideAsDataUri, isPlaywrightAvailable, createCaptureSession } from "../utils/export.mjs"
8
- import { publishToRegistry, registryItemExists, fetchRegistryItem, searchRegistry, updateLockfileItem, updateLockfilePublishConfig, readLockfile, writeLockfile, hashContent, detectPackageManager, requestUploadTokens, uploadBinaryToBlob, assetFileToSlug, detectAssetDepsInContent, readDeckMeta, updateDeckMeta, readItemMeta, updateItemMeta } from "../utils/registry.mjs"
8
+ import { publishToRegistry, registryItemExists, fetchRegistryItem, searchRegistry, updateLockfileItem, updateLockfilePublishConfig, readLockfile, writeLockfile, hashContent, detectPackageManager, requestUploadTokens, uploadBinaryToBlob, assetFileToSlug, detectAssetDepsInContent, readDeckMeta, updateDeckMeta, readItemMeta, updateItemMeta, pruneMissingLockfileItems } from "../utils/registry.mjs"
9
9
  import { prompt, confirm, select, closePrompts } from "../utils/prompts.mjs"
10
10
  import { parseDeckConfig } from "../utils/deck-config.mjs"
11
+ import { selectSlidesForPublish } from "../utils/deck-sync.mjs"
11
12
 
12
13
  const __dirname = dirname(fileURLToPath(import.meta.url))
13
14
  const CLI_VERSION = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8")).version
@@ -412,7 +413,7 @@ export async function publish(args) {
412
413
  console.log()
413
414
 
414
415
  if (args[0] === "--help" || args[0] === "-h") {
415
- console.log(` ${bold("Usage:")} promptslide publish ${dim("[file] [--type slide|layout|deck|theme]")}`)
416
+ console.log(` ${bold("Usage:")} promptslide publish ${dim("[file] [--type slide|layout|deck|theme] [--all]")}`)
416
417
  console.log()
417
418
  console.log(` Publish a slide, layout, or entire deck to the registry.`)
418
419
  console.log()
@@ -421,6 +422,9 @@ export async function publish(args) {
421
422
  console.log(` promptslide publish --type layout`)
422
423
  console.log(` promptslide publish --type deck`)
423
424
  console.log()
425
+ console.log(` ${bold("Options:")}`)
426
+ console.log(` --all Include slide files not referenced by deck-config.ts in a deck publish`)
427
+ console.log()
424
428
  process.exit(0)
425
429
  }
426
430
 
@@ -433,6 +437,7 @@ export async function publish(args) {
433
437
 
434
438
  // Determine file to publish
435
439
  let typeOverride = null
440
+ const includeUnreferencedSlides = args.includes("--all")
436
441
  const typeIdx = args.indexOf("--type")
437
442
  if (typeIdx !== -1 && args[typeIdx + 1]) {
438
443
  typeOverride = args[typeIdx + 1]
@@ -538,9 +543,23 @@ export async function publish(args) {
538
543
  : []
539
544
 
540
545
  const slidesDir = join(cwd, "src", "slides")
541
- const slideEntries = existsSync(slidesDir)
546
+ const slideFilesOnDisk = existsSync(slidesDir)
542
547
  ? readdirSync(slidesDir).filter(f => f.endsWith(".tsx") || f.endsWith(".ts"))
543
548
  : []
549
+ const slideSelection = selectSlidesForPublish(
550
+ slideFilesOnDisk,
551
+ deckConfig.slides,
552
+ includeUnreferencedSlides
553
+ )
554
+ if (slideSelection.missing.length > 0) {
555
+ console.error(` ${red("Error:")} deck-config.ts references slide files that do not exist:`)
556
+ console.error(` ${slideSelection.missing.join(", ")}`)
557
+ console.error(` ${dim("Restore the files or remove their entries from deck-config.ts before publishing.")}`)
558
+ console.log()
559
+ closePrompts()
560
+ process.exit(1)
561
+ }
562
+ const slideEntries = slideSelection.selected
544
563
 
545
564
  // Discover shared source files (src/components/, src/lib/, etc.)
546
565
  const sharedSources = discoverSharedSources(cwd)
@@ -555,12 +574,14 @@ export async function publish(args) {
555
574
  console.log(` Slides: ${slideEntries.length}`)
556
575
  if (sharedSources.length) console.log(` Shared: ${sharedSources.length} ${dim("(bundled with deck)")}`)
557
576
  console.log(` Total: ${totalItems} items`)
558
- // Info if there are slide files on disk not referenced in deck-config.ts
559
- const deckConfigSlideCount = deckConfig.slides.length
560
- if (deckConfigSlideCount !== slideEntries.length) {
577
+ if (slideSelection.unreferenced.length > 0) {
561
578
  console.log()
562
- console.log(` ${dim("ℹ")} deck-config.ts has ${deckConfigSlideCount} slides, ${slideEntries.length} slide files on disk`)
563
- console.log(` ${dim("All slides are published. Only deck-config slides appear in the deck preview.")}`)
579
+ if (includeUnreferencedSlides) {
580
+ console.log(` ${yellow("!")} Including ${slideSelection.unreferenced.length} slide file(s) not referenced by deck-config.ts because --all was set:`)
581
+ } else {
582
+ console.log(` ${dim("ℹ")} Skipping ${slideSelection.unreferenced.length} slide file(s) not referenced by deck-config.ts:`)
583
+ }
584
+ console.log(` ${dim(slideSelection.unreferenced.join(", "))}`)
564
585
  }
565
586
  if (deckConfig.transition) {
566
587
  console.log(` Transition: ${deckConfig.transition}${deckConfig.directionalTransition ? " (directional)" : ""}`)
@@ -612,6 +633,11 @@ export async function publish(args) {
612
633
 
613
634
  // Read lockfile for skip-if-unchanged
614
635
  const lock = readLockfile(cwd)
636
+ const prunedLockItems = pruneMissingLockfileItems(cwd, lock)
637
+ if (prunedLockItems.length > 0) {
638
+ writeLockfile(cwd, lock)
639
+ console.log(` ${dim("Pruned missing files from lockfile:")} ${prunedLockItems.join(", ")}`)
640
+ }
615
641
  let published = 0
616
642
  let skipped = 0
617
643
  let failed = 0
@@ -20,6 +20,7 @@ import {
20
20
  addSlideToDeckConfig,
21
21
  replaceDeckConfig
22
22
  } from "../utils/deck-config.mjs"
23
+ import { collectRegistrySlideSlugs, partitionDeckSlides } from "../utils/deck-sync.mjs"
23
24
  import { confirm, closePrompts } from "../utils/prompts.mjs"
24
25
 
25
26
  export async function pull(args) {
@@ -174,9 +175,15 @@ export async function pull(args) {
174
175
 
175
176
  // Update deck-config.ts
176
177
  if (deckItem.type === "deck" && deckItem.meta?.slides) {
178
+ const registrySlideSlugs = collectRegistrySlideSlugs(resolved.items)
179
+ const { available, missing } = partitionDeckSlides(deckItem.meta.slides, registrySlideSlugs)
180
+ if (missing.length > 0) {
181
+ console.log(` ${yellow("!")} Skipped ${missing.length} slide(s) listed in the deck but not included in this version:`)
182
+ console.log(` ${dim(missing.map(s => s.slug).join(", "))}`)
183
+ }
177
184
  const shouldReplace = await confirm(" Replace deck-config.ts with pulled deck config?", true)
178
185
  if (shouldReplace) {
179
- const slides = deckItem.meta.slides.map(s => ({
186
+ const slides = available.map(s => ({
180
187
  componentName: s.componentName || toPascalCase(s.slug),
181
188
  importPath: `@/slides/${s.slug}`,
182
189
  steps: s.steps,
@@ -189,7 +196,7 @@ export async function pull(args) {
189
196
  console.log(` ${green("+")} Replaced ${cyan("deck-config.ts")} ${dim(`(${slides.length} slides)`)}`)
190
197
  } else {
191
198
  // Append individual slides
192
- for (const s of deckItem.meta.slides) {
199
+ for (const s of available) {
193
200
  const componentName = s.componentName || toPascalCase(s.slug)
194
201
  const importPath = `@/slides/${s.slug}`
195
202
  const updated = addSlideToDeckConfig(cwd, { componentName, importPath, steps: s.steps, section: s.section })
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Return the slide slugs backed by files in a resolved registry dependency set.
3
+ * Only registry-provided files count; scaffold files already present in a target
4
+ * directory must not make stale deck metadata look valid.
5
+ *
6
+ * @param {object[]} items
7
+ * @returns {Set<string>}
8
+ */
9
+ export function collectRegistrySlideSlugs(items) {
10
+ const slugs = new Set()
11
+
12
+ for (const item of items ?? []) {
13
+ for (const file of item.files ?? []) {
14
+ const target = String(file.target ?? "")
15
+ .replaceAll("\\", "/")
16
+ .replace(/^\.\//, "")
17
+ const path = String(file.path ?? "")
18
+ .replaceAll("\\", "/")
19
+ .replace(/^\.\//, "")
20
+ const fullPath = `${target.replace(/\/?$/, "/")}${path}`.replace(/\/+/g, "/")
21
+ const match = fullPath.match(/^src\/slides\/(.+)\.tsx?$/)
22
+ if (match) slugs.add(match[1])
23
+ }
24
+ }
25
+
26
+ return slugs
27
+ }
28
+
29
+ /**
30
+ * Split deck metadata into entries backed by registry files and stale entries.
31
+ * Array order is preserved so generated deck configs match the published order.
32
+ *
33
+ * @param {object[]} slides
34
+ * @param {Set<string>} availableSlugs
35
+ * @returns {{ available: object[], missing: object[] }}
36
+ */
37
+ export function partitionDeckSlides(slides, availableSlugs) {
38
+ const available = []
39
+ const missing = []
40
+
41
+ for (const slide of slides ?? []) {
42
+ if (availableSlugs.has(slide.slug)) available.push(slide)
43
+ else missing.push(slide)
44
+ }
45
+
46
+ return { available, missing }
47
+ }
48
+
49
+ /**
50
+ * Select slide files for deck publishing. By default, only files referenced by
51
+ * deck-config.ts are included. The opt-in flag retains the legacy publish-all
52
+ * behavior for users who intentionally publish scratch or library slides.
53
+ *
54
+ * @param {string[]} slideEntries
55
+ * @param {{ slug: string }[]} configuredSlides
56
+ * @param {boolean} includeUnreferenced
57
+ * @returns {{ selected: string[], unreferenced: string[], missing: string[] }}
58
+ */
59
+ export function selectSlidesForPublish(
60
+ slideEntries,
61
+ configuredSlides,
62
+ includeUnreferenced = false
63
+ ) {
64
+ const filesBySlug = new Map(slideEntries.map(file => [file.replace(/\.tsx?$/, ""), file]))
65
+ const configuredSlugs = new Set(configuredSlides.map(slide => slide.slug))
66
+ const unreferenced = slideEntries.filter(
67
+ file => !configuredSlugs.has(file.replace(/\.tsx?$/, ""))
68
+ )
69
+ const missing = configuredSlides.map(slide => slide.slug).filter(slug => !filesBySlug.has(slug))
70
+ const selected = includeUnreferenced
71
+ ? [...slideEntries]
72
+ : configuredSlides.flatMap(slide => {
73
+ const file = filesBySlug.get(slide.slug)
74
+ return file ? [file] : []
75
+ })
76
+
77
+ return { selected, unreferenced, missing }
78
+ }
@@ -150,6 +150,29 @@ export function removeLockfileItem(cwd, slug) {
150
150
  writeLockfile(cwd, lock)
151
151
  }
152
152
 
153
+ /**
154
+ * Remove lockfile entries whose tracked files have all disappeared locally.
155
+ * Entries with no files (such as the deck manifest) are retained.
156
+ *
157
+ * @param {string} cwd
158
+ * @param {{ items?: Record<string, { files?: Record<string, string> }> }} lock
159
+ * @returns {string[]} Removed item slugs
160
+ */
161
+ export function pruneMissingLockfileItems(cwd, lock) {
162
+ const removed = []
163
+ lock.items ||= {}
164
+
165
+ for (const [slug, item] of Object.entries(lock.items)) {
166
+ const paths = Object.keys(item.files ?? {})
167
+ if (paths.length > 0 && paths.every(path => !existsSync(join(cwd, path)))) {
168
+ delete lock.items[slug]
169
+ removed.push(slug)
170
+ }
171
+ }
172
+
173
+ return removed
174
+ }
175
+
153
176
  /**
154
177
  * Build common auth headers for registry API requests.
155
178
  * @param {{ token: string, organizationId?: string }} auth