@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 +1 -1
- package/src/core/cli/index.js +4 -0
- package/src/core/cli/migrate.js +63 -0
- package/src/core/cli/updateVersion.js +38 -1
- package/src/core/migrations/0.10.0-primer-extraction.js +251 -0
- package/src/core/migrations/0.10.0-primer-extraction.test.js +158 -0
- package/src/core/migrations/detectCopilot.js +21 -0
- package/src/core/migrations/lockFile.js +54 -0
- package/src/core/migrations/lockFile.test.js +78 -0
- package/src/core/migrations/runner.js +208 -0
- package/src/core/migrations/runner.test.js +236 -0
- package/src/core/migrations/semver.js +47 -0
- package/src/core/migrations/semver.test.js +67 -0
package/package.json
CHANGED
package/src/core/cli/index.js
CHANGED
|
@@ -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
|
+
})
|