Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/init-pins-runtime-versions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'stash': patch
---

`stash init` now pins the packages it installs (`@cipherstash/stack`, the
integration adapter, and `stash` itself) to the exact versions this CLI
release was built alongside, instead of installing bare package names that
resolve through npm dist-tags (#661). During a pre-release window dist-tags
lag or point at placeholders, so an unpinned `init` could silently deliver a
different release than the CLI driving the setup — stale `@cipherstash/stack`,
or an empty placeholder adapter — breaking `/v3` imports out of the box. The
versions are embedded at build time from the release train itself
(`src/release-train.ts`, the single source both the build and the runtime
check against), so they can never disagree with what was published together.

Init also now surfaces **version skew** on already-installed packages —
unconditionally, before any prompt or early exit, including when the install
is declined or partially fails. Interactively it offers to align the skewed
packages in the same confirm as the missing installs (keeping `stash` a dev
dependency); non-interactively it never mutates an existing install — it
warns and prints the exact align commands. A package whose manifest exists
but can't be read (an aborted install) is reported as skew, not treated as
matching. All other install guidance is pinned the same way: the
missing-package hints, `.cipherstash/context.json`'s `installCommand`, the
`install-eql` manual note, the native-module recovery hint (previously
`stash@latest`), and the `stash wizard` one-shot spawn (previously an
unpinned `npx @cipherstash/wizard`). The `stash-cli` skill documents the
behaviour, and the other bundled skills' manual install commands now carry a
verify-what-resolved note.
49 changes: 49 additions & 0 deletions packages/cli/src/__tests__/release-train.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { INTEGRATION_ADAPTER_PACKAGES } from '../commands/init/steps/install-deps.js'
import { RELEASE_TRAIN_MANIFESTS } from '../release-train.js'

const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')

// The growth guard for #661: `RELEASE_TRAIN_MANIFESTS` is the single source
// the build embeds versions from, and `INTEGRATION_ADAPTER_PACKAGES` is what
// init installs. An adapter present in the second but absent from the first
// would install UNPINNED and be invisible to the skew warning — silently
// reintroducing the dist-tag failure mode on exactly the newest package. This
// suite turns that omission into a red test.
describe('release train coverage', () => {
it('every integration adapter package rides the release train', () => {
for (const pkg of Object.values(INTEGRATION_ADAPTER_PACKAGES)) {
expect(
RELEASE_TRAIN_MANIFESTS,
`${pkg} is installed by init but missing from RELEASE_TRAIN_MANIFESTS — it would install unpinned`,
).toHaveProperty([pkg])
}
})

it('the core packages and every one-shot-executed package are on the train', () => {
// stash: init self-installs it; @cipherstash/stack: the client;
// @cipherstash/wizard: EXECUTED via `npx` from `stash wizard` / the impl
// handoff, so an unpinned run would execute a different release.
for (const pkg of ['stash', '@cipherstash/stack', '@cipherstash/wizard']) {
expect(RELEASE_TRAIN_MANIFESTS).toHaveProperty([pkg])
}
})

it('every train manifest exists and carries a version (what tsup will embed)', () => {
// Exercises the exact inputs tsup.config.ts reads at build time, so a
// renamed/moved workspace package fails HERE in source-mode tests, not
// only at the next build.
for (const [pkg, rel] of Object.entries(RELEASE_TRAIN_MANIFESTS)) {
const manifest: unknown = JSON.parse(
readFileSync(resolve(CLI_ROOT, rel), 'utf8'),
)
const m = manifest as { name?: unknown; version?: unknown }
expect(m.name, `${rel} package name`).toBe(pkg)
expect(typeof m.version, `${pkg} version`).toBe('string')
expect((m.version as string).length).toBeGreaterThan(0)
}
})
})
96 changes: 96 additions & 0 deletions packages/cli/src/__tests__/runtime-versions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import {
expectedVersion,
parseEmbeddedVersions,
pinnedSpec,
RUNTIME_PACKAGE_VERSIONS,
} from '../runtime-versions.js'

// Source-mode runs (this test) never see the tsup define, so the embedded map
// must be empty and every helper must degrade to the unpinned behaviour —
// that fallback is itself part of the contract (dev/`tsx` runs keep working).
describe('runtime-versions (no build-time embed)', () => {
it('exposes an empty version map', () => {
expect(RUNTIME_PACKAGE_VERSIONS).toEqual({})
})

it('pinnedSpec falls back to the bare package name', () => {
expect(pinnedSpec('@cipherstash/stack')).toBe('@cipherstash/stack')
})

it('expectedVersion is undefined', () => {
expect(expectedVersion('@cipherstash/stack')).toBeUndefined()
})
})

// The embed parser is what stands between a build defect and silently
// unpinned installs (#661): absent (source mode) is fine; present-but-broken
// must THROW, never degrade to {}.
describe('parseEmbeddedVersions', () => {
it('absent embed (source mode) yields an empty map', () => {
expect(parseEmbeddedVersions(undefined)).toEqual({})
})

it('parses a valid embed', () => {
expect(
parseEmbeddedVersions('{"stash":"9.9.9-test.1","@x/y":"8.8.8-test.2"}'),
).toEqual({ stash: '9.9.9-test.1', '@x/y': '8.8.8-test.2' })
})

it('throws on unparseable JSON instead of degrading to unpinned', () => {
expect(() => parseEmbeddedVersions('{not json')).toThrow(
/build defect.*not valid JSON/s,
)
})

it('throws on a non-object shape', () => {
expect(() => parseEmbeddedVersions('["stash"]')).toThrow(
/not a plain object/,
)
expect(() => parseEmbeddedVersions('null')).toThrow(/not a plain object/)
expect(() => parseEmbeddedVersions('"9.9.9"')).toThrow(/not a plain object/)
})

it('throws on a missing/non-string version value', () => {
expect(() => parseEmbeddedVersions('{"stash":""}')).toThrow(
/usable version for "stash"/,
)
expect(() => parseEmbeddedVersions('{"stash":42}')).toThrow(
/usable version for "stash"/,
)
})
})

describe('runtime-versions (explicit version map)', () => {
// Arbitrary FIXTURE versions, deliberately unreal: these tests assert the
// map is threaded through verbatim, not that any actual release exists.
// Real versions are never hard-coded anywhere — the shipped map is read
// from the workspace manifests at build time (tsup.config.ts).
const versions = {
stash: '9.9.9-test.1',
'@cipherstash/stack': '9.9.9-test.1',
'@cipherstash/prisma-next': '8.8.8-test.2',
}

it('pins known packages to the release version', () => {
expect(pinnedSpec('@cipherstash/stack', versions)).toBe(
'@cipherstash/stack@9.9.9-test.1',
)
// prisma-next versions on its own line — the map, not a shared constant,
// is the source of truth.
expect(pinnedSpec('@cipherstash/prisma-next', versions)).toBe(
'@cipherstash/prisma-next@8.8.8-test.2',
)
})

it('leaves unknown packages unpinned', () => {
expect(pinnedSpec('@cipherstash/not-on-the-train', versions)).toBe(
'@cipherstash/not-on-the-train',
)
})

it('expectedVersion reads the map', () => {
expect(expectedVersion('stash', versions)).toBe('9.9.9-test.1')
expect(expectedVersion('nope', versions)).toBeUndefined()
})
})
3 changes: 2 additions & 1 deletion packages/cli/src/bin/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
wizardCommand,
} from '../commands/index.js'
import { messages } from '../messages.js'
import { pinnedSpec } from '../runtime-versions.js'
import {
classifyCommand,
classifyErrorType,
Expand Down Expand Up @@ -81,7 +82,7 @@ async function requireStack<T>(importFn: () => Promise<T>): Promise<T> {
if (isModuleNotFound(err)) {
p.log.error(
`@cipherstash/stack is required for this command.
Install it with: ${prodInstallCommand(PM, '@cipherstash/stack')}
Install it with: ${prodInstallCommand(PM, pinnedSpec('@cipherstash/stack'))}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Or run: ${STASH} init`,
)
throw new CliExit(1)
Expand Down
51 changes: 51 additions & 0 deletions packages/cli/src/commands/init/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
detectPackageManager,
devInstallCommand,
installedVersion,
isPackageInstalled,
prodInstallCommand,
runnerCommand,
Expand Down Expand Up @@ -192,3 +193,53 @@ describe('isPackageInstalled', () => {
expect(isPackageInstalled('@cipherstash/stack')).toBe(true)
})
})

// Real-filesystem coverage: installedVersion drives the #661 skew warning, so
// its read/parse/degrade behaviour is exercised against actual manifests here
// (everywhere else it's mocked).
describe('installedVersion', () => {
let tmp: string
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined

function writeManifest(pkg: string, contents: string) {
const dir = join(tmp, 'node_modules', ...pkg.split('/'))
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'package.json'), contents)
}

beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'installed-version-test-'))
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmp)
})

afterEach(() => {
cwdSpy?.mockRestore()
rmSync(tmp, { recursive: true, force: true })
})

it('reads the resolved version from node_modules (scoped package)', () => {
writeManifest(
'@cipherstash/stack',
JSON.stringify({ name: '@cipherstash/stack', version: '9.9.9-test.1' }),
)
expect(installedVersion('@cipherstash/stack')).toBe('9.9.9-test.1')
})

it('returns undefined for an absent package', () => {
expect(installedVersion('@cipherstash/stack')).toBeUndefined()
})

it('returns undefined for a corrupt manifest (aborted install)', () => {
writeManifest('@cipherstash/stack', '{ truncated')
expect(installedVersion('@cipherstash/stack')).toBeUndefined()
// ...which the caller (versionSkew) reports as a broken install rather
// than treating as a matching one — see install-deps.test.ts.
})

it('returns undefined for a manifest without a usable version', () => {
writeManifest('pkg-no-version', JSON.stringify({ name: 'pkg-no-version' }))
expect(installedVersion('pkg-no-version')).toBeUndefined()
writeManifest('pkg-empty-version', JSON.stringify({ version: '' }))
expect(installedVersion('pkg-empty-version')).toBeUndefined()
})
})
3 changes: 2 additions & 1 deletion packages/cli/src/commands/init/lib/write-context.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { pinnedSpec } from '../../../runtime-versions.js'
import type {
HandoffChoice,
InitState,
Expand Down Expand Up @@ -102,7 +103,7 @@ export function buildContextFile(state: InitState): ContextFile {
integration,
encryptionClientPath: clientFilePath,
packageManager: pm,
installCommand: prodInstallCommand(pm, '@cipherstash/stack'),
installCommand: prodInstallCommand(pm, pinnedSpec('@cipherstash/stack')),
envKeys: [],
schemas: state.schemas ?? [],
installedSkills: [],
Expand Down
Loading
Loading