@@ -13,6 +13,9 @@ vi.mock('../../utils.js', () => ({
1313 ...( dev . length ? [ `npm install --save-dev ${ dev . join ( ' ' ) } ` ] : [ ] ) ,
1414 ] ,
1515 ) ,
16+ devInstallCommand : vi . fn (
17+ ( _pm : string , pkg : string ) => `npm install -D ${ pkg } ` ,
18+ ) ,
1619 detectPackageManager : vi . fn ( ( ) => 'npm' ) ,
1720} ) )
1821// Pin map: pretend this CLI release was built alongside these versions, so
@@ -26,7 +29,10 @@ const FIXTURE_VERSIONS: Record<string, string> = vi.hoisted(() => ({
2629 '@cipherstash/stack' : '9.9.9-test.1' ,
2730 '@cipherstash/stack-supabase' : '9.9.9-test.1' ,
2831} ) )
29- vi . mock ( '../../../../runtime-versions.js' , ( ) => ( {
32+ vi . mock ( '../../../../runtime-versions.js' , async ( importOriginal ) => ( {
33+ // Keep the real pure helpers (compareVersions, parseEmbeddedVersions);
34+ // override only the release map and the map-reading functions.
35+ ...( await importOriginal < typeof import ( '../../../../runtime-versions.js' ) > ( ) ) ,
3036 RUNTIME_PACKAGE_VERSIONS : FIXTURE_VERSIONS ,
3137 expectedVersion : (
3238 pkg : string ,
@@ -216,6 +222,58 @@ describe('installDepsStep', () => {
216222 expect ( result . cliInstalled ) . toBe ( true )
217223 } )
218224
225+ it ( 'a NEWER install gets an update-stash warning, never a downgrade command' , async ( ) => {
226+ vi . mocked ( isInteractive ) . mockReturnValue ( false )
227+ present ( '@cipherstash/stack' , 'stash' )
228+ resolvedVersions ( {
229+ '@cipherstash/stack' : '9.9.10' ,
230+ stash : FIXTURE_VERSIONS . stash ,
231+ } )
232+
233+ const result = await installDepsStep . run ( baseState , provider )
234+
235+ expect ( p . log . warn ) . toHaveBeenCalledWith (
236+ expect . stringContaining (
237+ '@cipherstash/stack: installed 9.9.10 is newer than this release of stash expects (9.9.9-test.1)' ,
238+ ) ,
239+ )
240+ // Lockstep means stash@<installed> must exist — the warning prints the
241+ // exact update command instead of an uncommanded "matching release".
242+ expect ( p . log . warn ) . toHaveBeenCalledWith (
243+ expect . stringContaining ( 'npm install -D stash@9.9.10' ) ,
244+ )
245+ // No align/downgrade guidance, no mutation, clean success.
246+ expect ( p . note ) . not . toHaveBeenCalled ( )
247+ expect ( execSyncMock ) . not . toHaveBeenCalled ( )
248+ expect ( result . stackInstalled ) . toBe ( true )
249+ } )
250+
251+ it ( 'mixed ahead + missing: warns that fresh installs pin to THIS release and may mismatch' , async ( ) => {
252+ vi . mocked ( isInteractive ) . mockReturnValue ( false )
253+ // stack is installed and NEWER; the supabase adapter is missing — init
254+ // will install the adapter at this CLI's older embed, so it must say the
255+ // pairing may not match rather than silently manufacturing a cross-train
256+ // mismatch under a "likely fine" banner.
257+ present ( '@cipherstash/stack' , 'stash' )
258+ resolvedVersions ( {
259+ '@cipherstash/stack' : '9.9.10' ,
260+ stash : FIXTURE_VERSIONS . stash ,
261+ } )
262+
263+ await installDepsStep . run ( baseState , supabaseProvider )
264+
265+ expect ( p . log . warn ) . toHaveBeenCalledWith (
266+ expect . stringContaining (
267+ "@cipherstash/stack-supabase will be installed at THIS release's versions" ,
268+ ) ,
269+ )
270+ expect ( p . log . warn ) . toHaveBeenCalledWith (
271+ expect . stringContaining ( 'npm install -D stash@9.9.10' ) ,
272+ )
273+ // The missing adapter still installs (non-interactive default).
274+ expect ( execSyncMock ) . toHaveBeenCalled ( )
275+ } )
276+
219277 it ( 'reports an unreadable manifest as skew, not as a matching install' , async ( ) => {
220278 vi . mocked ( isInteractive ) . mockReturnValue ( false )
221279 present ( '@cipherstash/stack' , 'stash' )
@@ -257,6 +315,24 @@ describe('versionSkew', () => {
257315 pkg : '@cipherstash/stack' ,
258316 installed : '0.19.0' ,
259317 expected : '9.9.9-test.1' ,
318+ direction : 'behind' ,
319+ } ,
320+ ] )
321+ } )
322+
323+ it ( 'classifies a newer-than-expected install as ahead' , ( ) => {
324+ present ( '@cipherstash/stack' )
325+ resolvedVersions ( { '@cipherstash/stack' : '9.9.10' } )
326+ expect (
327+ versionSkew ( [ '@cipherstash/stack' ] , {
328+ '@cipherstash/stack' : '9.9.9-test.1' ,
329+ } ) ,
330+ ) . toEqual ( [
331+ {
332+ pkg : '@cipherstash/stack' ,
333+ installed : '9.9.10' ,
334+ expected : '9.9.9-test.1' ,
335+ direction : 'ahead' ,
260336 } ,
261337 ] )
262338 } )
@@ -274,6 +350,23 @@ describe('versionSkew', () => {
274350 expect ( versionSkew ( [ '@no-map/package' ] , { } ) ) . toEqual ( [ ] )
275351 } )
276352
353+ it ( 'classifies a garbage installed version as behind (align guidance), not ahead' , ( ) => {
354+ present ( '@cipherstash/stack' )
355+ resolvedVersions ( { '@cipherstash/stack' : 'not-a-version' } )
356+ expect (
357+ versionSkew ( [ '@cipherstash/stack' ] , {
358+ '@cipherstash/stack' : '9.9.9-test.1' ,
359+ } ) ,
360+ ) . toEqual ( [
361+ {
362+ pkg : '@cipherstash/stack' ,
363+ installed : 'not-a-version' ,
364+ expected : '9.9.9-test.1' ,
365+ direction : 'behind' ,
366+ } ,
367+ ] )
368+ } )
369+
277370 it ( 'flags an installed package with an unreadable manifest' , ( ) => {
278371 present ( '@cipherstash/stack' )
279372 resolvedVersions ( { '@cipherstash/stack' : undefined } )
@@ -286,6 +379,8 @@ describe('versionSkew', () => {
286379 pkg : '@cipherstash/stack' ,
287380 installed : 'unknown (unreadable package.json)' ,
288381 expected : '9.9.9-test.1' ,
382+ // Unreadable = broken install → offer the (re)install fix.
383+ direction : 'behind' ,
289384 } ,
290385 ] )
291386 } )
0 commit comments