@dfosco/storyboard 0.10.3 → 0.10.4-beta.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard",
3
- "version": "0.10.3",
3
+ "version": "0.10.4-beta.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -147,6 +147,7 @@ function helpScreen(version) {
147
147
  ` ${bold(cyan('Updates'))}`,
148
148
  cmd('update', 'Update storyboard packages to latest'),
149
149
  cmd('update:<tag>', 'Update to a specific tag ' + dim('(beta, alpha, ...)')),
150
+ cmd('migrate', 'Re-run version-bridge migrations'),
150
151
  '',
151
152
  ` ${dim('All create commands accept --flags for non-interactive use.')}`,
152
153
  ` ${dim('Run')} ${yellow('npx storyboard create <type> --help')} ${dim('for flag details.')}`,
@@ -304,6 +305,9 @@ switch (command) {
304
305
  case 'workleaf':
305
306
  import('./workleaf.js')
306
307
  break
308
+ case 'migrate':
309
+ import('./migrate.js')
310
+ break
307
311
  default: {
308
312
  if (command === 'update' || (command && command.startsWith('update:'))) {
309
313
  import('./updateVersion.js')
@@ -0,0 +1,63 @@
1
+ /**
2
+ * storyboard migrate — re-run version-bridge migrations outside of an upgrade.
3
+ *
4
+ * Usage:
5
+ * storyboard migrate Replay any pending migrations against
6
+ * the currently-installed @dfosco/storyboard
7
+ * version (fromVersion = 0.0.0 covers everything).
8
+ * storyboard migrate --from <v> Override fromVersion (useful when re-running
9
+ * after an aborted upgrade or to force replay
10
+ * — does NOT bypass the lock file).
11
+ * storyboard migrate --yes Auto-accept the copilot finalize prompt.
12
+ *
13
+ * Migrations are still gated by storyboard.lock — once an id is recorded, it
14
+ * will not run again. Delete the relevant entry from storyboard.lock if you
15
+ * truly need to replay.
16
+ */
17
+
18
+ import * as p from '@clack/prompts'
19
+ import { readFileSync } from 'fs'
20
+ import { resolve } from 'path'
21
+ import { runMigrations } from '../migrations/runner.js'
22
+
23
+ const args = process.argv.slice(3)
24
+ const assumeYes = args.includes('--yes') || args.includes('-y')
25
+ const fromIndex = args.indexOf('--from')
26
+ const fromOverride = fromIndex >= 0 ? args[fromIndex + 1] : null
27
+
28
+ function readInstalledVersion() {
29
+ try {
30
+ const pkg = JSON.parse(
31
+ readFileSync(resolve(process.cwd(), 'node_modules', '@dfosco', 'storyboard', 'package.json'), 'utf8')
32
+ )
33
+ return pkg.version || null
34
+ } catch {
35
+ return null
36
+ }
37
+ }
38
+
39
+ const installed = readInstalledVersion()
40
+ if (!installed) {
41
+ p.log.error('Could not determine installed @dfosco/storyboard version — is it installed?')
42
+ process.exit(1)
43
+ }
44
+
45
+ p.intro('storyboard migrate')
46
+ p.log.info(`from ${fromOverride || '0.0.0'} → ${installed}`)
47
+
48
+ try {
49
+ const result = await runMigrations({
50
+ fromVersion: fromOverride || '0.0.0',
51
+ toVersion: installed,
52
+ cwd: process.cwd(),
53
+ assumeYes,
54
+ })
55
+ const summary = []
56
+ if (result.ran.length) summary.push(`${result.ran.length} ran`)
57
+ if (result.failed.length) summary.push(`${result.failed.length} failed`)
58
+ if (result.skipped.length) summary.push(`${result.skipped.length} skipped`)
59
+ p.outro(summary.length ? summary.join(', ') : 'nothing to do')
60
+ } catch (err) {
61
+ p.log.error(`Migration runner failed: ${err.message}`)
62
+ process.exit(1)
63
+ }
@@ -13,10 +13,28 @@ import * as p from '@clack/prompts'
13
13
  import { execSync } from 'child_process'
14
14
  import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'fs'
15
15
  import { dirname, resolve } from 'path'
16
+ import { runMigrations } from '../migrations/runner.js'
16
17
 
17
18
  const command = process.argv[2]
18
19
  const channels = { 'update:beta': 'beta', 'update:alpha': 'alpha' }
19
20
 
21
+ // --yes / -y skips migration confirmation prompts (codemods still run; the
22
+ // copilot finalize step auto-accepts instead of asking).
23
+ const assumeYes = process.argv.includes('--yes') || process.argv.includes('-y')
24
+
25
+ function readInstalledStoryboardVersion() {
26
+ try {
27
+ const pkg = JSON.parse(
28
+ readFileSync(resolve(process.cwd(), 'node_modules', '@dfosco', 'storyboard', 'package.json'), 'utf8')
29
+ )
30
+ return pkg.version || null
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ const preUpdateVersion = readInstalledStoryboardVersion() || '0.0.0'
37
+
20
38
  let channel, targetVersion
21
39
  if (channels[command]) {
22
40
  channel = channels[command]
@@ -128,6 +146,23 @@ function checkArtifactDiscoveryWorkflow() {
128
146
  p.log.info('Checking cross-branch artifact workflow integration…')
129
147
  checkArtifactDiscoveryWorkflow()
130
148
 
149
+ // Run version-bridge migrations between the pre-install version and the
150
+ // freshly-installed one. Each migration's codemods run deterministically;
151
+ // anything contextual is handed off to the `copilot` CLI when present, or
152
+ // printed as a paste-into-your-AI-assistant prompt when absent.
153
+ p.log.info('Running migrations…')
154
+ try {
155
+ const postUpdateVersion = readInstalledStoryboardVersion() || preUpdateVersion
156
+ await runMigrations({
157
+ fromVersion: preUpdateVersion,
158
+ toVersion: postUpdateVersion,
159
+ cwd: process.cwd(),
160
+ assumeYes,
161
+ })
162
+ } catch (err) {
163
+ p.log.warn(`Migrations failed: ${err.message}`)
164
+ }
165
+
131
166
  // Auto-commit the version update (only if package.json or lock file changed)
132
167
  try {
133
168
  // Read the installed version from the storyboard package (try unified first, then legacy core)
@@ -144,12 +179,14 @@ try {
144
179
  installedVersion = installedVersion || suffix.slice(1)
145
180
  const commitMsg = `[storyboard-update] Update storyboard to ${installedVersion}`
146
181
 
147
- // Only stage update-related files (package.json, lock files, scaffold outputs)
182
+ // Only stage update-related files (package.json, lock files, scaffold outputs,
183
+ // migration lock).
148
184
  const filesToStage = [
149
185
  'package.json',
150
186
  'package-lock.json',
151
187
  'yarn.lock',
152
188
  'pnpm-lock.yaml',
189
+ 'storyboard.lock',
153
190
  '.github/skills',
154
191
  '.github/workflows/scripts/aggregate-artifacts.mjs',
155
192
  'scripts',
@@ -0,0 +1,251 @@
1
+ /**
2
+ * 0.10.0-primer-extraction — finishes the migration to the consumer-wrapper
3
+ * architecture introduced in storyboard 0.10.0.
4
+ *
5
+ * Storyboard 0.10.0 removed the `@dfosco/storyboard/primer` subpath export
6
+ * and stopped wrapping prototypes/stories in <ThemeProvider><BaseStyles>.
7
+ * Consumers now wire their design system through src/_prototype.jsx and
8
+ * src/_story.jsx. The library SPA entry moved to src/library/mount.jsx and
9
+ * is Primer-free.
10
+ *
11
+ * What this codemod does deterministically:
12
+ * 1. Rewrites index.html's <script src> from the legacy /src/index.jsx
13
+ * entry to /src/library/mount.jsx (if both ends exist).
14
+ * 2. Scaffolds src/library/PrimerThemeSync.jsx (the bridge component that
15
+ * used to live in @dfosco/storyboard/primer) — only when the consumer's
16
+ * legacy entry actually imported it AND the file doesn't exist yet.
17
+ *
18
+ * What goes to the AI prompt (everything contextual):
19
+ * - Moving the Primer <ThemeProvider><BaseStyles><ThemeSync /> wrap from
20
+ * the deleted entry into src/_prototype.jsx + src/_story.jsx.
21
+ * - Re-homing any root-level JSX siblings (e.g. global dialogs) from the
22
+ * old entry. These can sit alongside mount.jsx via a small user file or
23
+ * be merged into a wrapper component — depends on the project.
24
+ * - Merging extra `handlers:` keys from the legacy mountStoryboardCore
25
+ * call into the new mount.jsx call.
26
+ * - Deleting src/index.jsx once verified.
27
+ * - Running `npm run dev` and confirming prototype routes still theme.
28
+ */
29
+
30
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
31
+ import { dirname, join } from 'path'
32
+
33
+ export const id = '0.10.0-primer-extraction'
34
+ export const fromRange = '<0.10.0'
35
+ export const toRange = '>=0.10.0'
36
+
37
+ const LEGACY_ENTRY = 'src/index.jsx'
38
+ const NEW_ENTRY = 'src/library/mount.jsx'
39
+ const INDEX_HTML = 'index.html'
40
+ const PRIMER_SUBPATH_IMPORT = '@dfosco/storyboard/primer'
41
+ const THEME_SYNC_TARGET = 'src/library/PrimerThemeSync.jsx'
42
+
43
+ /** Migration only fires for repos that actually used the removed primer subpath. */
44
+ export async function detect({ cwd }) {
45
+ const legacy = join(cwd, LEGACY_ENTRY)
46
+ if (!existsSync(legacy)) return false
47
+ try {
48
+ return readFileSync(legacy, 'utf8').includes(PRIMER_SUBPATH_IMPORT)
49
+ } catch {
50
+ return false
51
+ }
52
+ }
53
+
54
+ export const codemods = [
55
+ {
56
+ name: 'rewrite-index-html-entry',
57
+ intent:
58
+ `Rewrite ${INDEX_HTML} <script src="/${LEGACY_ENTRY}"> to ` +
59
+ `<script src="/${NEW_ENTRY}">, so the SPA boots from the new ` +
60
+ `Primer-free library entry.`,
61
+ async run({ cwd }) {
62
+ const newEntryAbs = join(cwd, NEW_ENTRY)
63
+ if (!existsSync(newEntryAbs)) {
64
+ throw new Error(`${NEW_ENTRY} does not exist yet — re-run storyboard-scaffold first`)
65
+ }
66
+ const htmlPath = join(cwd, INDEX_HTML)
67
+ if (!existsSync(htmlPath)) {
68
+ throw new Error(`${INDEX_HTML} not found at repo root`)
69
+ }
70
+ const html = readFileSync(htmlPath, 'utf8')
71
+ const legacyMatcher = /(<script\b[^>]*\bsrc=["'])\/?src\/index\.jsx(["'][^>]*>)/
72
+ if (!legacyMatcher.test(html)) {
73
+ // Either already on /src/library/mount.jsx, or uses a non-standard
74
+ // entry path we don't recognise — bail safely so the AI prompt can
75
+ // pick it up.
76
+ if (html.includes(`/${NEW_ENTRY}`)) return
77
+ throw new Error(`could not find <script src="/${LEGACY_ENTRY}"> in ${INDEX_HTML}`)
78
+ }
79
+ const rewritten = html.replace(legacyMatcher, `$1/${NEW_ENTRY}$2`)
80
+ writeFileSync(htmlPath, rewritten)
81
+ },
82
+ },
83
+ {
84
+ name: 'scaffold-primer-themesync',
85
+ intent:
86
+ `Scaffold ${THEME_SYNC_TARGET} (the bridge component formerly at ` +
87
+ `@dfosco/storyboard/primer) so src/_prototype.jsx can import it.`,
88
+ async run({ cwd }) {
89
+ const target = join(cwd, THEME_SYNC_TARGET)
90
+ if (existsSync(target)) return
91
+ mkdirSync(dirname(target), { recursive: true })
92
+ writeFileSync(target, PRIMER_THEMESYNC_SOURCE)
93
+ },
94
+ },
95
+ ]
96
+
97
+ export const copilotPrompt = `
98
+ # Storyboard 0.10.0 migration — finalize Primer extraction
99
+
100
+ Storyboard 0.10.0 removed the \`@dfosco/storyboard/primer\` subpath export and
101
+ decoupled storyboard core from \`@primer/react\`. Consumers now wire their
102
+ design system through two seams:
103
+
104
+ - \`src/_prototype.jsx\` — wraps every prototype route (workspace SPA + iframe)
105
+ - \`src/_story.jsx\` — wraps every story render
106
+
107
+ The deterministic codemods just rewrote \`index.html\` to load the new
108
+ \`src/library/mount.jsx\` entry and scaffolded \`src/library/PrimerThemeSync.jsx\`.
109
+ What's left needs project-specific judgment:
110
+
111
+ ## 1. Move the Primer wrap into the consumer seams
112
+
113
+ Inspect the legacy \`src/index.jsx\` (still present until you delete it) and
114
+ move its Primer provider stack into \`src/_prototype.jsx\`. Replace the
115
+ current pass-through with something like:
116
+
117
+ \`\`\`jsx
118
+ import { ThemeProvider, BaseStyles } from '@primer/react'
119
+ import './globals.css'
120
+ import PrimerThemeSync from './library/PrimerThemeSync.jsx'
121
+
122
+ export default function PrototypeWrapper({ children }) {
123
+ return (
124
+ <ThemeProvider colorMode="auto">
125
+ <BaseStyles>
126
+ <PrimerThemeSync />
127
+ {children}
128
+ </BaseStyles>
129
+ </ThemeProvider>
130
+ )
131
+ }
132
+ \`\`\`
133
+
134
+ Then make \`src/_story.jsx\` re-export the same wrapper:
135
+
136
+ \`\`\`jsx
137
+ export { default } from './_prototype.jsx'
138
+ \`\`\`
139
+
140
+ ## 2. Re-home root-level JSX siblings
141
+
142
+ The old \`src/index.jsx\` likely renders extra components alongside
143
+ \`<RouterProvider>\` (global dialogs, listeners, toasters, etc). These were
144
+ mounted at the SPA root. In 0.10.0 the SPA shell is \`src/library/_app.jsx\` —
145
+ but that file is library-owned and should not be edited.
146
+
147
+ Pick one of:
148
+ - **Mount inside \`_prototype.jsx\`** — works if the component is only
149
+ needed while a prototype is visible (it'll unmount on workspace routes).
150
+ - **Add a sibling file** like \`src/userExtensions.jsx\` exporting a small
151
+ component, and import + render it from \`src/library/_app.jsx\` (this
152
+ counts as a documented library edit — see src/library/README.md).
153
+
154
+ ## 3. Merge \`mountStoryboardCore\` options
155
+
156
+ If the legacy entry passed extra \`handlers\` or other options to
157
+ \`mountStoryboardCore(storyboardConfig, { ... })\`, merge them into the
158
+ matching call in \`src/library/mount.jsx\`.
159
+
160
+ ## 4. Delete the legacy entry and verify
161
+
162
+ Once items 1–3 are done, delete \`src/index.jsx\`, run \`npm run dev\`, and
163
+ confirm:
164
+ - Workspace and canvas routes load.
165
+ - Prototype routes are themed correctly (Primer base styles applied).
166
+ - Theme toggling in the toolbar updates prototypes as expected.
167
+ `
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // PrimerThemeSync source — pulled out of git history (commit ae9e4c122~1).
171
+ // Kept verbatim so the bridge behavior on consumer repos is unchanged from
172
+ // pre-0.10.0.
173
+ // ---------------------------------------------------------------------------
174
+
175
+ const PRIMER_THEMESYNC_SOURCE = `/**
176
+ * PrimerThemeSync — invisible React component that bridges the storyboard
177
+ * toolbar theme switcher with Primer's ThemeProvider context.
178
+ *
179
+ * Listens for \`storyboard:theme:changed\` custom events dispatched by the
180
+ * core theme store and calls setColorMode/setDayScheme/setNightScheme on
181
+ * Primer's useTheme() hook accordingly.
182
+ *
183
+ * On mount it reads localStorage to initialize Primer to the correct scheme
184
+ * before the storyboard toolbar has loaded.
185
+ *
186
+ * When prototype sync is disabled (via "Apply theme to" settings), the
187
+ * prototype is forced to light mode regardless of the selected theme.
188
+ *
189
+ * Scaffolded by the 0.10.0-primer-extraction migration. Replaces the
190
+ * \`ThemeSync\` component that used to live at \`@dfosco/storyboard/primer\`
191
+ * (removed when storyboard core became Primer-agnostic). Safe to edit.
192
+ */
193
+
194
+ import { useEffect } from 'react'
195
+ import { useTheme } from '@primer/react'
196
+
197
+ const THEME_STORAGE_KEY = 'sb-color-scheme'
198
+ const THEME_SYNC_STORAGE_KEY = 'sb-theme-sync'
199
+
200
+ const DEFAULT_SYNC = {
201
+ prototype: true,
202
+ toolbar: false,
203
+ codeBoxes: true,
204
+ }
205
+
206
+ function readSyncTargets() {
207
+ try {
208
+ const raw = localStorage.getItem(THEME_SYNC_STORAGE_KEY)
209
+ if (!raw) return DEFAULT_SYNC
210
+ return { ...DEFAULT_SYNC, ...JSON.parse(raw) }
211
+ } catch {
212
+ return DEFAULT_SYNC
213
+ }
214
+ }
215
+
216
+ function applyToPrimer(setColorMode, setDayScheme, setNightScheme, themeValue) {
217
+ if (themeValue === 'system' || !themeValue) {
218
+ setColorMode('auto')
219
+ setDayScheme('light')
220
+ setNightScheme('dark')
221
+ } else {
222
+ setColorMode('day')
223
+ setDayScheme(themeValue)
224
+ setNightScheme(themeValue)
225
+ }
226
+ }
227
+
228
+ export default function PrimerThemeSync() {
229
+ const { setColorMode, setDayScheme, setNightScheme } = useTheme()
230
+
231
+ useEffect(() => {
232
+ const saved = localStorage.getItem(THEME_STORAGE_KEY)
233
+ const syncTargets = readSyncTargets()
234
+ const prototypeTheme = syncTargets.prototype ? saved : 'light'
235
+ applyToPrimer(setColorMode, setDayScheme, setNightScheme, prototypeTheme)
236
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
237
+
238
+ useEffect(() => {
239
+ function handleThemeChanged(e) {
240
+ const detail = e.detail || {}
241
+ const prototypeTheme = detail.bySurface?.prototype ?? detail.prototypeTheme
242
+ if (typeof prototypeTheme !== 'string') return
243
+ applyToPrimer(setColorMode, setDayScheme, setNightScheme, prototypeTheme)
244
+ }
245
+ document.addEventListener('storyboard:theme:changed', handleThemeChanged)
246
+ return () => document.removeEventListener('storyboard:theme:changed', handleThemeChanged)
247
+ }, [setColorMode, setDayScheme, setNightScheme])
248
+
249
+ return null
250
+ }
251
+ `
@@ -0,0 +1,158 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
3
+ import { tmpdir } from 'os'
4
+ import { join } from 'path'
5
+
6
+ import { runMigrations } from './runner.js'
7
+ import * as migration from './0.10.0-primer-extraction.js'
8
+
9
+ let cwd
10
+ let logs
11
+
12
+ const LEGACY_INDEX_JSX = `
13
+ import { StrictMode } from 'react'
14
+ import { createRoot } from 'react-dom/client'
15
+ import { RouterProvider, createBrowserRouter } from 'react-router-dom'
16
+ import { routes } from './routes'
17
+ import { ThemeProvider, BaseStyles } from '@primer/react'
18
+ import { ThemeSync } from '@dfosco/storyboard/primer'
19
+ `
20
+
21
+ const INDEX_HTML_WITH_LEGACY_ENTRY = `<!doctype html>
22
+ <html><body>
23
+ <div id="root"></div>
24
+ <script type="module" src="/src/index.jsx"></script>
25
+ </body></html>
26
+ `
27
+
28
+ const INDEX_HTML_WITH_NEW_ENTRY = `<!doctype html>
29
+ <html><body>
30
+ <div id="root"></div>
31
+ <script type="module" src="/src/library/mount.jsx"></script>
32
+ </body></html>
33
+ `
34
+
35
+ function captureLog() {
36
+ const entries = { info: [], message: [], warn: [], step: [], error: [] }
37
+ return {
38
+ entries,
39
+ info: (m) => entries.info.push(m),
40
+ message: (m) => entries.message.push(m),
41
+ warn: (m) => entries.warn.push(m),
42
+ step: (m) => entries.step.push(m),
43
+ error: (m) => entries.error.push(m),
44
+ }
45
+ }
46
+
47
+ function scaffoldClient(cwd, { withLegacyImport = true, withMountJsx = true } = {}) {
48
+ mkdirSync(join(cwd, 'src'), { recursive: true })
49
+ writeFileSync(
50
+ join(cwd, 'src/index.jsx'),
51
+ withLegacyImport ? LEGACY_INDEX_JSX : '// nothing here\n',
52
+ )
53
+ if (withMountJsx) {
54
+ mkdirSync(join(cwd, 'src/library'), { recursive: true })
55
+ writeFileSync(join(cwd, 'src/library/mount.jsx'), '// mount\n')
56
+ }
57
+ writeFileSync(join(cwd, 'index.html'), INDEX_HTML_WITH_LEGACY_ENTRY)
58
+ }
59
+
60
+ beforeEach(() => {
61
+ cwd = mkdtempSync(join(tmpdir(), 'sb-mig010-'))
62
+ logs = captureLog()
63
+ })
64
+
65
+ afterEach(() => {
66
+ rmSync(cwd, { recursive: true, force: true })
67
+ })
68
+
69
+ describe('0.10.0-primer-extraction — detect()', () => {
70
+ it('returns true when legacy entry imports @dfosco/storyboard/primer', async () => {
71
+ scaffoldClient(cwd)
72
+ expect(await migration.detect({ cwd })).toBe(true)
73
+ })
74
+
75
+ it('returns false when legacy entry does not import the removed subpath', async () => {
76
+ scaffoldClient(cwd, { withLegacyImport: false })
77
+ expect(await migration.detect({ cwd })).toBe(false)
78
+ })
79
+
80
+ it('returns false when there is no legacy entry at all', async () => {
81
+ expect(await migration.detect({ cwd })).toBe(false)
82
+ })
83
+ })
84
+
85
+ describe('0.10.0-primer-extraction — codemods via runner', () => {
86
+ it('rewrites index.html and scaffolds PrimerThemeSync, records ran', async () => {
87
+ scaffoldClient(cwd)
88
+ const result = await runMigrations({
89
+ fromVersion: '0.9.5', toVersion: '0.10.3',
90
+ cwd, modules: [migration], log: logs,
91
+ prompts: { confirm: vi.fn().mockResolvedValue(false) },
92
+ detectCopilot: () => null, runCopilot: vi.fn(),
93
+ })
94
+
95
+ expect(result.ran).toEqual(['0.10.0-primer-extraction'])
96
+ expect(readFileSync(join(cwd, 'index.html'), 'utf8'))
97
+ .toContain('src="/src/library/mount.jsx"')
98
+ expect(existsSync(join(cwd, 'src/library/PrimerThemeSync.jsx'))).toBe(true)
99
+ expect(readFileSync(join(cwd, 'src/library/PrimerThemeSync.jsx'), 'utf8'))
100
+ .toContain("import { useTheme } from '@primer/react'")
101
+
102
+ // Lock recorded — re-running is a no-op
103
+ const result2 = await runMigrations({
104
+ fromVersion: '0.9.5', toVersion: '0.10.3',
105
+ cwd, modules: [migration], log: logs,
106
+ prompts: { confirm: vi.fn() },
107
+ detectCopilot: () => null, runCopilot: vi.fn(),
108
+ })
109
+ expect(result2.ran).toEqual([])
110
+ })
111
+
112
+ it('fails gracefully when index.html points to a non-standard entry', async () => {
113
+ scaffoldClient(cwd)
114
+ writeFileSync(
115
+ join(cwd, 'index.html'),
116
+ '<html><script type="module" src="/src/wat.jsx"></script></html>',
117
+ )
118
+ const result = await runMigrations({
119
+ fromVersion: '0.9.5', toVersion: '0.10.3',
120
+ cwd, modules: [migration], log: logs,
121
+ prompts: { confirm: vi.fn().mockResolvedValue(false) },
122
+ detectCopilot: () => null, runCopilot: vi.fn(),
123
+ })
124
+ expect(result.failed).toEqual(['0.10.0-primer-extraction'])
125
+ const printed = logs.entries.message.join('\n')
126
+ expect(printed).toContain('Codemods that failed')
127
+ expect(printed).toContain('rewrite-index-html-entry')
128
+ expect(printed).toContain('could not find')
129
+ })
130
+
131
+ it('is a no-op for index.html that already uses the new entry', async () => {
132
+ scaffoldClient(cwd)
133
+ writeFileSync(join(cwd, 'index.html'), INDEX_HTML_WITH_NEW_ENTRY)
134
+ const result = await runMigrations({
135
+ fromVersion: '0.9.5', toVersion: '0.10.3',
136
+ cwd, modules: [migration], log: logs,
137
+ prompts: { confirm: vi.fn().mockResolvedValue(false) },
138
+ detectCopilot: () => null, runCopilot: vi.fn(),
139
+ })
140
+ expect(result.ran).toEqual(['0.10.0-primer-extraction'])
141
+ expect(readFileSync(join(cwd, 'index.html'), 'utf8'))
142
+ .toContain('src="/src/library/mount.jsx"')
143
+ })
144
+
145
+ it('does not overwrite an existing PrimerThemeSync', async () => {
146
+ scaffoldClient(cwd)
147
+ mkdirSync(join(cwd, 'src/library'), { recursive: true })
148
+ writeFileSync(join(cwd, 'src/library/PrimerThemeSync.jsx'), '// custom\n')
149
+ await runMigrations({
150
+ fromVersion: '0.9.5', toVersion: '0.10.3',
151
+ cwd, modules: [migration], log: logs,
152
+ prompts: { confirm: vi.fn().mockResolvedValue(false) },
153
+ detectCopilot: () => null, runCopilot: vi.fn(),
154
+ })
155
+ expect(readFileSync(join(cwd, 'src/library/PrimerThemeSync.jsx'), 'utf8'))
156
+ .toBe('// custom\n')
157
+ })
158
+ })
@@ -0,0 +1,21 @@
1
+ /**
2
+ * detectCopilot — checks whether `copilot` is on PATH.
3
+ * Returns 'copilot' when present, null otherwise.
4
+ *
5
+ * Intentionally narrow: we don't probe for `gh copilot`, `claude`, `codex`,
6
+ * or any other CLI. The fallback path prints the prompt directly so the
7
+ * user can hand it to whichever assistant they're already using.
8
+ */
9
+
10
+ import { spawnSync } from 'child_process'
11
+
12
+ export function detectCopilot() {
13
+ try {
14
+ const probe = spawnSync('command', ['-v', 'copilot'], {
15
+ stdio: 'ignore',
16
+ shell: true,
17
+ })
18
+ if (probe.status === 0) return 'copilot'
19
+ } catch { /* fall through */ }
20
+ return null
21
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * storyboard.lock — append-only record of which migrations have run in a
3
+ * consumer repository. Lives at the consumer repo root, committed.
4
+ *
5
+ * Schema (version 1):
6
+ * {
7
+ * "version": 1,
8
+ * "migrations": [
9
+ * { "id": "...", "status": "ran" | "skipped" | "failed",
10
+ * "at": "<ISO>", "reason"?: string }
11
+ * ]
12
+ * }
13
+ *
14
+ * Once a migration id appears in the array, the runner never replays it —
15
+ * even if status is "failed" (failures are handed off to the AI prompt and
16
+ * considered "delivered to the user"; replaying would re-run codemods the
17
+ * user may have already finished by hand).
18
+ */
19
+
20
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
21
+ import { join } from 'path'
22
+
23
+ const LOCK_FILENAME = 'storyboard.lock'
24
+ const SCHEMA_VERSION = 1
25
+
26
+ function lockPath(cwd) {
27
+ return join(cwd, LOCK_FILENAME)
28
+ }
29
+
30
+ export function readLock(cwd) {
31
+ const path = lockPath(cwd)
32
+ if (!existsSync(path)) return { version: SCHEMA_VERSION, migrations: [] }
33
+ try {
34
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
35
+ if (!parsed || typeof parsed !== 'object') return { version: SCHEMA_VERSION, migrations: [] }
36
+ return {
37
+ version: parsed.version || SCHEMA_VERSION,
38
+ migrations: Array.isArray(parsed.migrations) ? parsed.migrations : [],
39
+ }
40
+ } catch {
41
+ return { version: SCHEMA_VERSION, migrations: [] }
42
+ }
43
+ }
44
+
45
+ export function hasRun(lock, id) {
46
+ return lock.migrations.some((m) => m.id === id)
47
+ }
48
+
49
+ export function appendMigration(cwd, entry) {
50
+ const lock = readLock(cwd)
51
+ lock.migrations.push({ at: new Date().toISOString(), ...entry })
52
+ writeFileSync(lockPath(cwd), JSON.stringify(lock, null, 2) + '\n')
53
+ return lock
54
+ }
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'fs'
3
+ import { tmpdir } from 'os'
4
+ import { join } from 'path'
5
+
6
+ import { readLock, hasRun, appendMigration } from './lockFile.js'
7
+
8
+ let cwd
9
+
10
+ beforeEach(() => {
11
+ cwd = mkdtempSync(join(tmpdir(), 'sb-lockfile-'))
12
+ })
13
+
14
+ afterEach(() => {
15
+ rmSync(cwd, { recursive: true, force: true })
16
+ })
17
+
18
+ describe('readLock', () => {
19
+ it('returns empty shape when storyboard.lock does not exist', () => {
20
+ const lock = readLock(cwd)
21
+ expect(lock).toEqual({ version: 1, migrations: [] })
22
+ })
23
+
24
+ it('parses an existing lock file', () => {
25
+ writeFileSync(
26
+ join(cwd, 'storyboard.lock'),
27
+ JSON.stringify({ version: 1, migrations: [{ id: 'a', status: 'ran' }] })
28
+ )
29
+ const lock = readLock(cwd)
30
+ expect(lock.migrations).toHaveLength(1)
31
+ expect(lock.migrations[0].id).toBe('a')
32
+ })
33
+
34
+ it('returns empty shape when lock is malformed JSON', () => {
35
+ writeFileSync(join(cwd, 'storyboard.lock'), 'not json')
36
+ expect(readLock(cwd)).toEqual({ version: 1, migrations: [] })
37
+ })
38
+ })
39
+
40
+ describe('hasRun', () => {
41
+ it('returns true when id is present regardless of status', () => {
42
+ const lock = { version: 1, migrations: [
43
+ { id: 'a', status: 'ran' },
44
+ { id: 'b', status: 'failed' },
45
+ { id: 'c', status: 'skipped' },
46
+ ]}
47
+ expect(hasRun(lock, 'a')).toBe(true)
48
+ expect(hasRun(lock, 'b')).toBe(true)
49
+ expect(hasRun(lock, 'c')).toBe(true)
50
+ expect(hasRun(lock, 'd')).toBe(false)
51
+ })
52
+ })
53
+
54
+ describe('appendMigration', () => {
55
+ it('creates the file and appends an entry', () => {
56
+ appendMigration(cwd, { id: 'a', status: 'ran' })
57
+ const lock = readLock(cwd)
58
+ expect(lock.migrations).toHaveLength(1)
59
+ expect(lock.migrations[0].id).toBe('a')
60
+ expect(lock.migrations[0].status).toBe('ran')
61
+ expect(lock.migrations[0].at).toMatch(/T.*Z/)
62
+ })
63
+
64
+ it('appends to existing entries', () => {
65
+ appendMigration(cwd, { id: 'a', status: 'ran' })
66
+ appendMigration(cwd, { id: 'b', status: 'failed' })
67
+ const lock = readLock(cwd)
68
+ expect(lock.migrations).toHaveLength(2)
69
+ expect(lock.migrations.map(m => m.id)).toEqual(['a', 'b'])
70
+ })
71
+
72
+ it('writes pretty-printed JSON with trailing newline', () => {
73
+ appendMigration(cwd, { id: 'a', status: 'ran' })
74
+ const raw = readFileSync(join(cwd, 'storyboard.lock'), 'utf8')
75
+ expect(raw.endsWith('\n')).toBe(true)
76
+ expect(raw).toContain(' "migrations":')
77
+ })
78
+ })
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Migration runner — discovers migration modules, filters by version + lock,
3
+ * runs deterministic codemods, then hands the AI-assisted finish to copilot
4
+ * (or prints the prompt for the user to paste elsewhere).
5
+ *
6
+ * Each migration module exports:
7
+ * id string required, unique
8
+ * fromRange string (semver range) required
9
+ * toRange string (semver range) required
10
+ * detect async ({ cwd }) => boolean optional (default: true)
11
+ * codemods Array<{ name, intent, run }> optional
12
+ * copilotPrompt string optional
13
+ *
14
+ * Codemod contract:
15
+ * { name, intent, async run({ cwd, log }) }
16
+ * - intent is a short human-readable sentence describing what the codemod
17
+ * attempts to do. If run() throws, the intent gets appended to the
18
+ * copilot prompt under "Codemods that failed — please apply manually".
19
+ *
20
+ * Failure semantics:
21
+ * - codemod throws → recorded internally, intent merged into prompt
22
+ * - migration always finishes (status: ran), unless detect() returns false
23
+ * (status: skipped). The lock is append-only and never re-runs an id.
24
+ */
25
+
26
+ import * as p from '@clack/prompts'
27
+ import { execSync, spawn } from 'child_process'
28
+ import { readdirSync } from 'fs'
29
+ import { join } from 'path'
30
+ import { pathToFileURL } from 'url'
31
+
32
+ import { satisfies } from './semver.js'
33
+ import { readLock, hasRun, appendMigration } from './lockFile.js'
34
+ import { detectCopilot } from './detectCopilot.js'
35
+
36
+ const MIGRATIONS_DIR = new URL('.', import.meta.url).pathname
37
+
38
+ async function loadMigrationModules(dir = MIGRATIONS_DIR) {
39
+ const entries = readdirSync(dir, { withFileTypes: true })
40
+ const mods = []
41
+ for (const entry of entries) {
42
+ if (!entry.isFile()) continue
43
+ if (!/^\d/.test(entry.name)) continue
44
+ if (!entry.name.endsWith('.js')) continue
45
+ if (entry.name.endsWith('.test.js')) continue
46
+ const mod = await import(pathToFileURL(join(dir, entry.name)).href)
47
+ if (!mod.id) continue
48
+ mods.push(mod)
49
+ }
50
+ return mods
51
+ }
52
+
53
+ function isApplicable(mod, fromVersion, toVersion) {
54
+ return (
55
+ satisfies(fromVersion, mod.fromRange) &&
56
+ satisfies(toVersion, mod.toRange)
57
+ )
58
+ }
59
+
60
+ function buildFailureAppendix(failures) {
61
+ if (failures.length === 0) return ''
62
+ const lines = ['', '## Codemods that failed — please apply manually', '']
63
+ for (const f of failures) {
64
+ lines.push(`- **${f.name}** — ${f.intent}`)
65
+ if (f.error) lines.push(` - Error: \`${f.error}\``)
66
+ }
67
+ return lines.join('\n') + '\n'
68
+ }
69
+
70
+ function printPromptFallback(id, prompt, log) {
71
+ const border = '─'.repeat(64)
72
+ log.info(`Manual step needed: ${id}`)
73
+ log.message('Paste the following prompt into your AI coding assistant:')
74
+ log.message('')
75
+ log.message(border)
76
+ log.message(prompt.trim())
77
+ log.message(border)
78
+ }
79
+
80
+ async function invokeCopilot(prompt, cwd) {
81
+ return new Promise((resolve, reject) => {
82
+ const child = spawn('copilot', ['-p', prompt], { stdio: 'inherit', cwd })
83
+ child.on('error', reject)
84
+ child.on('exit', (code) => {
85
+ if (code === 0) resolve()
86
+ else reject(new Error(`copilot exited with code ${code}`))
87
+ })
88
+ })
89
+ }
90
+
91
+ /**
92
+ * @param {object} opts
93
+ * @param {string} opts.fromVersion
94
+ * @param {string} opts.toVersion
95
+ * @param {string} opts.cwd
96
+ * @param {boolean} [opts.assumeYes] — skip the copilot confirmation
97
+ * @param {Array} [opts.modules] — inject migrations (for tests)
98
+ * @param {object} [opts.log] — defaults to clack's p.log
99
+ * @param {object} [opts.prompts] — { confirm }, for tests
100
+ * @param {Function} [opts.runCopilot] — for tests
101
+ * @param {Function} [opts.detectCopilot] — for tests
102
+ */
103
+ export async function runMigrations(opts) {
104
+ const {
105
+ fromVersion,
106
+ toVersion,
107
+ cwd,
108
+ assumeYes = false,
109
+ modules,
110
+ log = p.log,
111
+ prompts = p,
112
+ runCopilot = invokeCopilot,
113
+ detectCopilot: detect = detectCopilot,
114
+ } = opts
115
+
116
+ const all = modules || (await loadMigrationModules())
117
+ const lock = readLock(cwd)
118
+
119
+ const candidates = all
120
+ .filter((m) => isApplicable(m, fromVersion, toVersion))
121
+ .filter((m) => !hasRun(lock, m.id))
122
+
123
+ if (candidates.length === 0) {
124
+ log.message('No migrations to run')
125
+ return { ran: [], skipped: [], failed: [] }
126
+ }
127
+
128
+ log.info(`Running ${candidates.length} migration(s)`)
129
+
130
+ const ran = []
131
+ const skipped = []
132
+ const failedMigrations = []
133
+
134
+ for (const mod of candidates) {
135
+ const applicable = mod.detect ? await mod.detect({ cwd }) : true
136
+ if (!applicable) {
137
+ appendMigration(cwd, { id: mod.id, status: 'skipped', reason: 'not-applicable' })
138
+ skipped.push(mod.id)
139
+ log.message(` ${mod.id} — skipped (not applicable)`)
140
+ continue
141
+ }
142
+
143
+ log.step(mod.id)
144
+
145
+ const failures = []
146
+ for (const cm of mod.codemods || []) {
147
+ try {
148
+ await cm.run({ cwd, log })
149
+ log.message(` ✔ ${cm.name}`)
150
+ } catch (err) {
151
+ failures.push({ name: cm.name, intent: cm.intent, error: err.message })
152
+ log.warn(` ✗ ${cm.name} — ${err.message}`)
153
+ }
154
+ }
155
+
156
+ if (mod.copilotPrompt) {
157
+ const promptWithFailures = mod.copilotPrompt.trim() + '\n' + buildFailureAppendix(failures)
158
+ const tool = detect()
159
+ if (tool) {
160
+ let ok = assumeYes
161
+ if (!assumeYes) {
162
+ ok = await prompts.confirm({
163
+ message: `Run \`${tool}\` to finalize ${mod.id}?`,
164
+ initialValue: true,
165
+ })
166
+ }
167
+ if (ok) {
168
+ try {
169
+ await runCopilot(promptWithFailures, cwd)
170
+ } catch (err) {
171
+ log.warn(`copilot failed: ${err.message} — printing prompt instead`)
172
+ printPromptFallback(mod.id, promptWithFailures, log)
173
+ }
174
+ } else {
175
+ printPromptFallback(mod.id, promptWithFailures, log)
176
+ }
177
+ } else {
178
+ printPromptFallback(mod.id, promptWithFailures, log)
179
+ }
180
+ } else if (failures.length > 0) {
181
+ printPromptFallback(
182
+ mod.id,
183
+ buildFailureAppendix(failures),
184
+ log,
185
+ )
186
+ }
187
+
188
+ appendMigration(cwd, {
189
+ id: mod.id,
190
+ status: failures.length > 0 ? 'failed' : 'ran',
191
+ ...(failures.length > 0 && { failures: failures.map((f) => f.name) }),
192
+ })
193
+ if (failures.length > 0) {
194
+ failedMigrations.push(mod.id)
195
+ } else {
196
+ ran.push(mod.id)
197
+ }
198
+ }
199
+
200
+ return { ran, skipped, failed: failedMigrations }
201
+ }
202
+
203
+ export { loadMigrationModules, buildFailureAppendix, printPromptFallback }
204
+ // re-export for callers that want to read the lock without importing the module
205
+ export { readLock, hasRun } from './lockFile.js'
206
+ // unused but kept exported for callers that want to invoke deterministic
207
+ // shell helpers from a migration's codemod
208
+ export { execSync as _execSyncForCodemods }
@@ -0,0 +1,236 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { mkdtempSync, rmSync } from 'fs'
3
+ import { tmpdir } from 'os'
4
+ import { join } from 'path'
5
+
6
+ import { runMigrations, buildFailureAppendix } from './runner.js'
7
+ import { readLock, hasRun } from './lockFile.js'
8
+
9
+ let cwd
10
+ let logs
11
+
12
+ const captureLog = () => {
13
+ const entries = { info: [], message: [], warn: [], step: [], error: [] }
14
+ return {
15
+ entries,
16
+ info: (m) => entries.info.push(m),
17
+ message: (m) => entries.message.push(m),
18
+ warn: (m) => entries.warn.push(m),
19
+ step: (m) => entries.step.push(m),
20
+ error: (m) => entries.error.push(m),
21
+ }
22
+ }
23
+
24
+ const noPrompt = { confirm: vi.fn().mockResolvedValue(true) }
25
+ const detectNone = () => null
26
+ const detectCopilotTool = () => 'copilot'
27
+
28
+ function makeMigration(overrides = {}) {
29
+ return {
30
+ id: 'test-mig',
31
+ fromRange: '<1.0.0',
32
+ toRange: '>=1.0.0',
33
+ codemods: [],
34
+ ...overrides,
35
+ }
36
+ }
37
+
38
+ beforeEach(() => {
39
+ cwd = mkdtempSync(join(tmpdir(), 'sb-migrations-'))
40
+ logs = captureLog()
41
+ })
42
+
43
+ afterEach(() => {
44
+ rmSync(cwd, { recursive: true, force: true })
45
+ })
46
+
47
+ describe('runMigrations — version filter', () => {
48
+ it('runs a migration when fromVersion satisfies fromRange and toVersion satisfies toRange', async () => {
49
+ const mig = makeMigration({
50
+ codemods: [{ name: 'noop', intent: '', run: vi.fn() }],
51
+ })
52
+ const result = await runMigrations({
53
+ fromVersion: '0.9.5', toVersion: '1.0.0',
54
+ cwd, modules: [mig], log: logs, prompts: noPrompt,
55
+ detectCopilot: detectNone, runCopilot: vi.fn(),
56
+ })
57
+ expect(result.ran).toEqual(['test-mig'])
58
+ expect(mig.codemods[0].run).toHaveBeenCalled()
59
+ })
60
+
61
+ it('skips a migration when toVersion does not satisfy toRange', async () => {
62
+ const mig = makeMigration({
63
+ codemods: [{ name: 'noop', intent: '', run: vi.fn() }],
64
+ })
65
+ const result = await runMigrations({
66
+ fromVersion: '0.9.5', toVersion: '0.9.8',
67
+ cwd, modules: [mig], log: logs, prompts: noPrompt,
68
+ detectCopilot: detectNone, runCopilot: vi.fn(),
69
+ })
70
+ expect(result.ran).toEqual([])
71
+ expect(mig.codemods[0].run).not.toHaveBeenCalled()
72
+ })
73
+ })
74
+
75
+ describe('runMigrations — lock file', () => {
76
+ it('records the migration as ran and never replays it', async () => {
77
+ const mig = makeMigration({
78
+ codemods: [{ name: 'noop', intent: '', run: vi.fn() }],
79
+ })
80
+ await runMigrations({
81
+ fromVersion: '0.9.5', toVersion: '1.0.0',
82
+ cwd, modules: [mig], log: logs, prompts: noPrompt,
83
+ detectCopilot: detectNone, runCopilot: vi.fn(),
84
+ })
85
+ const lock = readLock(cwd)
86
+ expect(hasRun(lock, 'test-mig')).toBe(true)
87
+
88
+ // Replay
89
+ mig.codemods[0].run.mockClear()
90
+ const result = await runMigrations({
91
+ fromVersion: '0.9.5', toVersion: '1.0.0',
92
+ cwd, modules: [mig], log: logs, prompts: noPrompt,
93
+ detectCopilot: detectNone, runCopilot: vi.fn(),
94
+ })
95
+ expect(mig.codemods[0].run).not.toHaveBeenCalled()
96
+ expect(result.ran).toEqual([])
97
+ })
98
+
99
+ it('records skipped when detect() returns false', async () => {
100
+ const mig = makeMigration({
101
+ detect: async () => false,
102
+ codemods: [{ name: 'noop', intent: '', run: vi.fn() }],
103
+ })
104
+ const result = await runMigrations({
105
+ fromVersion: '0.9.5', toVersion: '1.0.0',
106
+ cwd, modules: [mig], log: logs, prompts: noPrompt,
107
+ detectCopilot: detectNone, runCopilot: vi.fn(),
108
+ })
109
+ expect(result.skipped).toEqual(['test-mig'])
110
+ expect(mig.codemods[0].run).not.toHaveBeenCalled()
111
+ const lock = readLock(cwd)
112
+ expect(lock.migrations[0].status).toBe('skipped')
113
+ expect(lock.migrations[0].reason).toBe('not-applicable')
114
+ })
115
+ })
116
+
117
+ describe('runMigrations — codemod failures', () => {
118
+ it('records failed status and appends intent to the printed prompt', async () => {
119
+ const mig = makeMigration({
120
+ copilotPrompt: 'Finish the migration.',
121
+ codemods: [
122
+ { name: 'good', intent: 'do good things', run: vi.fn() },
123
+ {
124
+ name: 'bad',
125
+ intent: 'rewrite something tricky',
126
+ run: () => { throw new Error('regex did not match') },
127
+ },
128
+ ],
129
+ })
130
+ const result = await runMigrations({
131
+ fromVersion: '0.9.5', toVersion: '1.0.0',
132
+ cwd, modules: [mig], log: logs, prompts: noPrompt,
133
+ detectCopilot: detectNone, runCopilot: vi.fn(),
134
+ })
135
+ expect(result.failed).toEqual(['test-mig'])
136
+ expect(result.ran).toEqual([])
137
+
138
+ const printed = logs.entries.message.join('\n')
139
+ expect(printed).toContain('Finish the migration.')
140
+ expect(printed).toContain('Codemods that failed')
141
+ expect(printed).toContain('rewrite something tricky')
142
+ expect(printed).toContain('regex did not match')
143
+
144
+ const lock = readLock(cwd)
145
+ expect(lock.migrations[0].status).toBe('failed')
146
+ expect(lock.migrations[0].failures).toEqual(['bad'])
147
+ })
148
+
149
+ it('still prints failure appendix even when migration has no copilotPrompt', async () => {
150
+ const mig = makeMigration({
151
+ codemods: [{
152
+ name: 'bad', intent: 'do a thing',
153
+ run: () => { throw new Error('nope') },
154
+ }],
155
+ })
156
+ await runMigrations({
157
+ fromVersion: '0.9.5', toVersion: '1.0.0',
158
+ cwd, modules: [mig], log: logs, prompts: noPrompt,
159
+ detectCopilot: detectNone, runCopilot: vi.fn(),
160
+ })
161
+ const printed = logs.entries.message.join('\n')
162
+ expect(printed).toContain('Codemods that failed')
163
+ expect(printed).toContain('do a thing')
164
+ })
165
+ })
166
+
167
+ describe('runMigrations — copilot branch', () => {
168
+ it('runs copilot when detected and user confirms', async () => {
169
+ const mig = makeMigration({
170
+ copilotPrompt: 'Do the AI thing.',
171
+ })
172
+ const runCopilot = vi.fn().mockResolvedValue()
173
+ const confirm = vi.fn().mockResolvedValue(true)
174
+ await runMigrations({
175
+ fromVersion: '0.9.5', toVersion: '1.0.0',
176
+ cwd, modules: [mig], log: logs,
177
+ prompts: { confirm }, detectCopilot: detectCopilotTool, runCopilot,
178
+ })
179
+ expect(confirm).toHaveBeenCalled()
180
+ expect(runCopilot).toHaveBeenCalledOnce()
181
+ expect(runCopilot.mock.calls[0][0]).toContain('Do the AI thing.')
182
+ })
183
+
184
+ it('skips copilot prompt and prints fallback when user declines', async () => {
185
+ const mig = makeMigration({ copilotPrompt: 'Do the AI thing.' })
186
+ const runCopilot = vi.fn()
187
+ const confirm = vi.fn().mockResolvedValue(false)
188
+ await runMigrations({
189
+ fromVersion: '0.9.5', toVersion: '1.0.0',
190
+ cwd, modules: [mig], log: logs,
191
+ prompts: { confirm }, detectCopilot: detectCopilotTool, runCopilot,
192
+ })
193
+ expect(runCopilot).not.toHaveBeenCalled()
194
+ expect(logs.entries.message.join('\n')).toContain('Do the AI thing.')
195
+ })
196
+
197
+ it('auto-accepts copilot when assumeYes is set', async () => {
198
+ const mig = makeMigration({ copilotPrompt: 'Do the AI thing.' })
199
+ const runCopilot = vi.fn().mockResolvedValue()
200
+ const confirm = vi.fn()
201
+ await runMigrations({
202
+ fromVersion: '0.9.5', toVersion: '1.0.0',
203
+ cwd, modules: [mig], log: logs, assumeYes: true,
204
+ prompts: { confirm }, detectCopilot: detectCopilotTool, runCopilot,
205
+ })
206
+ expect(confirm).not.toHaveBeenCalled()
207
+ expect(runCopilot).toHaveBeenCalledOnce()
208
+ })
209
+
210
+ it('prints prompt fallback when copilot is not detected', async () => {
211
+ const mig = makeMigration({ copilotPrompt: 'Do the AI thing.' })
212
+ const runCopilot = vi.fn()
213
+ await runMigrations({
214
+ fromVersion: '0.9.5', toVersion: '1.0.0',
215
+ cwd, modules: [mig], log: logs, prompts: noPrompt,
216
+ detectCopilot: detectNone, runCopilot,
217
+ })
218
+ expect(runCopilot).not.toHaveBeenCalled()
219
+ expect(logs.entries.message.join('\n')).toContain('Do the AI thing.')
220
+ })
221
+ })
222
+
223
+ describe('buildFailureAppendix', () => {
224
+ it('returns empty string when no failures', () => {
225
+ expect(buildFailureAppendix([])).toBe('')
226
+ })
227
+
228
+ it('formats failures as a markdown section', () => {
229
+ const out = buildFailureAppendix([
230
+ { name: 'foo', intent: 'do a thing', error: 'oops' },
231
+ ])
232
+ expect(out).toContain('## Codemods that failed')
233
+ expect(out).toContain('**foo** — do a thing')
234
+ expect(out).toContain('`oops`')
235
+ })
236
+ })
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Minimal semver range checker for migration version filters.
3
+ *
4
+ * Supports the subset we actually use in fromRange/toRange:
5
+ * "X.Y.Z" — exact match
6
+ * ">=X.Y.Z" / ">X.Y.Z" / "<=X.Y.Z" / "<X.Y.Z"
7
+ * "*" — matches anything
8
+ *
9
+ * Pre-release tags (e.g. 0.10.0-beta.1) are normalized to their base version
10
+ * for comparison purposes — a migration with fromRange "<0.10.0" matches
11
+ * "0.10.0-beta.1" because the user has not yet reached the stable cut.
12
+ */
13
+
14
+ function parse(version) {
15
+ if (!version || typeof version !== 'string') return [0, 0, 0]
16
+ const base = version.split('-')[0].split('+')[0]
17
+ const parts = base.split('.').map((n) => Number.parseInt(n, 10))
18
+ return [parts[0] || 0, parts[1] || 0, parts[2] || 0]
19
+ }
20
+
21
+ function compare(a, b) {
22
+ const [a1, a2, a3] = parse(a)
23
+ const [b1, b2, b3] = parse(b)
24
+ if (a1 !== b1) return a1 - b1
25
+ if (a2 !== b2) return a2 - b2
26
+ return a3 - b3
27
+ }
28
+
29
+ export function satisfies(version, range) {
30
+ if (!range || range === '*') return true
31
+ const trimmed = range.trim()
32
+ const operatorMatch = trimmed.match(/^(>=|<=|>|<|=)?\s*(.+)$/)
33
+ if (!operatorMatch) return false
34
+ const op = operatorMatch[1] || '='
35
+ const target = operatorMatch[2]
36
+ const cmp = compare(version, target)
37
+ switch (op) {
38
+ case '>=': return cmp >= 0
39
+ case '<=': return cmp <= 0
40
+ case '>': return cmp > 0
41
+ case '<': return cmp < 0
42
+ case '=': return cmp === 0
43
+ default: return false
44
+ }
45
+ }
46
+
47
+ export { compare, parse }
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { satisfies, compare, parse } from './semver.js'
3
+
4
+ describe('semver.parse', () => {
5
+ it('parses standard 3-part versions', () => {
6
+ expect(parse('1.2.3')).toEqual([1, 2, 3])
7
+ })
8
+
9
+ it('strips pre-release tags', () => {
10
+ expect(parse('0.10.0-beta.1')).toEqual([0, 10, 0])
11
+ })
12
+
13
+ it('strips build metadata', () => {
14
+ expect(parse('1.2.3+abcdef')).toEqual([1, 2, 3])
15
+ })
16
+
17
+ it('returns zeros for missing parts', () => {
18
+ expect(parse('1')).toEqual([1, 0, 0])
19
+ expect(parse('1.2')).toEqual([1, 2, 0])
20
+ })
21
+
22
+ it('handles falsy input', () => {
23
+ expect(parse(undefined)).toEqual([0, 0, 0])
24
+ expect(parse('')).toEqual([0, 0, 0])
25
+ })
26
+ })
27
+
28
+ describe('semver.compare', () => {
29
+ it('orders versions correctly', () => {
30
+ expect(compare('1.0.0', '1.0.0')).toBe(0)
31
+ expect(compare('1.0.0', '1.0.1') < 0).toBe(true)
32
+ expect(compare('1.1.0', '1.0.5') > 0).toBe(true)
33
+ expect(compare('2.0.0', '1.99.99') > 0).toBe(true)
34
+ })
35
+ })
36
+
37
+ describe('semver.satisfies', () => {
38
+ it('"*" matches everything', () => {
39
+ expect(satisfies('0.0.0', '*')).toBe(true)
40
+ expect(satisfies('99.99.99', '*')).toBe(true)
41
+ })
42
+
43
+ it('exact match', () => {
44
+ expect(satisfies('1.0.0', '1.0.0')).toBe(true)
45
+ expect(satisfies('1.0.0', '=1.0.0')).toBe(true)
46
+ expect(satisfies('1.0.0', '1.0.1')).toBe(false)
47
+ })
48
+
49
+ it('>= and >', () => {
50
+ expect(satisfies('1.0.0', '>=1.0.0')).toBe(true)
51
+ expect(satisfies('1.0.0', '>1.0.0')).toBe(false)
52
+ expect(satisfies('1.0.1', '>1.0.0')).toBe(true)
53
+ })
54
+
55
+ it('<= and <', () => {
56
+ expect(satisfies('0.9.9', '<1.0.0')).toBe(true)
57
+ expect(satisfies('1.0.0', '<1.0.0')).toBe(false)
58
+ expect(satisfies('1.0.0', '<=1.0.0')).toBe(true)
59
+ })
60
+
61
+ it('treats pre-release as the base version', () => {
62
+ // a project on 0.10.0-beta.1 has NOT crossed the 0.10.0 stable cut
63
+ expect(satisfies('0.10.0-beta.1', '<0.10.0')).toBe(false) // matches 0.10.0 base
64
+ expect(satisfies('0.10.0-beta.1', '>=0.10.0')).toBe(true)
65
+ expect(satisfies('0.9.5-rc.2', '<0.10.0')).toBe(true)
66
+ })
67
+ })