From 6fcb96756aad01f57d308ea0d54f8fd7b948424f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 16 Jul 2026 15:21:22 +1000 Subject: [PATCH 1/3] fix(cli): pin init's installs to the release's own package versions (#661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stash init` installed runtime packages unpinned, resolving through npm dist-tags — which lag or point at placeholders during a pre-release window. The 2026-07-16 skilltester run showed the failure mode end to end: a bare `npm install` delivered `@cipherstash/stack@0.19.0` + `stack-drizzle@0.0.0` instead of rc.1, breaking `/v3` imports and silently making two eval surfaces test the wrong release. Embed the release train's exact versions at build time (tsup define, same mechanism as the PostHog key, but set on every build since it needs no env var; a missing workspace manifest fails the build rather than silently degrading to unpinned). `stash init` now installs `@cipherstash/stack`, the integration adapter, and `stash` itself pinned to those versions, and warns — never mutates — when an already-installed package's resolved node_modules version differs from the release's, printing the exact align command. The install guidance in missing-package hints and context.json is pinned the same way. Source-mode runs (unit tests, tsx) see no embed and keep today's bare names. Skill note added to stash-cli (init pins; treat the skew warning as real). Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .changeset/init-pins-runtime-versions.md | 21 ++++ .../src/__tests__/runtime-versions.test.ts | 53 +++++++++ packages/cli/src/bin/main.ts | 3 +- .../src/commands/init/lib/write-context.ts | 3 +- .../init/steps/__tests__/install-deps.test.ts | 108 +++++++++++++++++- .../src/commands/init/steps/install-deps.ts | 79 +++++++++++-- packages/cli/src/commands/init/utils.ts | 29 ++++- packages/cli/src/config/missing-package.ts | 7 +- packages/cli/src/runtime-versions.ts | 69 +++++++++++ packages/cli/tsup.config.ts | 53 ++++++++- skills/stash-cli/SKILL.md | 2 +- 11 files changed, 406 insertions(+), 21 deletions(-) create mode 100644 .changeset/init-pins-runtime-versions.md create mode 100644 packages/cli/src/__tests__/runtime-versions.test.ts create mode 100644 packages/cli/src/runtime-versions.ts diff --git a/.changeset/init-pins-runtime-versions.md b/.changeset/init-pins-runtime-versions.md new file mode 100644 index 000000000..ac84b3eeb --- /dev/null +++ b/.changeset/init-pins-runtime-versions.md @@ -0,0 +1,21 @@ +--- +'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, so they can +never disagree with what was published together. + +Init also now **warns on version skew**: if a `@cipherstash/*` package is +already installed but its resolved `node_modules` version differs from the one +this release expects, init says so and prints the exact command to align it +(it never mutates existing installs). The install guidance printed by other +commands (missing-package hints, `.cipherstash/context.json`'s +`installCommand`) is pinned the same way, and the `stash-cli` skill documents +the pinning and skew-warning behaviour. diff --git a/packages/cli/src/__tests__/runtime-versions.test.ts b/packages/cli/src/__tests__/runtime-versions.test.ts new file mode 100644 index 000000000..d1230b399 --- /dev/null +++ b/packages/cli/src/__tests__/runtime-versions.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { + expectedVersion, + 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() + }) +}) + +describe('runtime-versions (explicit version map)', () => { + const versions = { + stash: '1.0.0-rc.2', + '@cipherstash/stack': '1.0.0-rc.2', + '@cipherstash/prisma-next': '0.4.0-rc.2', + } + + it('pins known packages to the release version', () => { + expect(pinnedSpec('@cipherstash/stack', versions)).toBe( + '@cipherstash/stack@1.0.0-rc.2', + ) + // 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@0.4.0-rc.2', + ) + }) + + it('leaves unknown packages unpinned', () => { + expect(pinnedSpec('@cipherstash/stack-drizzle', versions)).toBe( + '@cipherstash/stack-drizzle', + ) + }) + + it('expectedVersion reads the map', () => { + expect(expectedVersion('stash', versions)).toBe('1.0.0-rc.2') + expect(expectedVersion('nope', versions)).toBeUndefined() + }) +}) diff --git a/packages/cli/src/bin/main.ts b/packages/cli/src/bin/main.ts index ee7e2ab7f..e4f6306b8 100644 --- a/packages/cli/src/bin/main.ts +++ b/packages/cli/src/bin/main.ts @@ -38,6 +38,7 @@ import { wizardCommand, } from '../commands/index.js' import { messages } from '../messages.js' +import { pinnedSpec } from '../runtime-versions.js' import { classifyCommand, classifyErrorType, @@ -81,7 +82,7 @@ async function requireStack(importFn: () => Promise): Promise { 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'))} Or run: ${STASH} init`, ) throw new CliExit(1) diff --git a/packages/cli/src/commands/init/lib/write-context.ts b/packages/cli/src/commands/init/lib/write-context.ts index 66d14e52a..da21bf9eb 100644 --- a/packages/cli/src/commands/init/lib/write-context.ts +++ b/packages/cli/src/commands/init/lib/write-context.ts @@ -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, @@ -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: [], diff --git a/packages/cli/src/commands/init/steps/__tests__/install-deps.test.ts b/packages/cli/src/commands/init/steps/__tests__/install-deps.test.ts index 2a1097e11..73f06a53b 100644 --- a/packages/cli/src/commands/init/steps/__tests__/install-deps.test.ts +++ b/packages/cli/src/commands/init/steps/__tests__/install-deps.test.ts @@ -6,12 +6,30 @@ vi.mock('node:child_process', () => ({ execSync: execSyncMock })) // Collaborators from utils — control install state + commands without a real PM. vi.mock('../../utils.js', () => ({ isPackageInstalled: vi.fn(() => false), - combinedInstallCommands: vi.fn(() => [ - 'npm install @cipherstash/stack', - 'npm install --save-dev stash', - ]), + installedVersion: vi.fn(() => undefined), + combinedInstallCommands: vi.fn( + (_pm: string, prod: string[], dev: string[]) => [ + ...(prod.length ? [`npm install ${prod.join(' ')}`] : []), + ...(dev.length ? [`npm install --save-dev ${dev.join(' ')}`] : []), + ], + ), detectPackageManager: vi.fn(() => 'npm'), })) +// Pin map: pretend this CLI release was built alongside these versions, so +// the pinned-spec and skew paths are exercisable from source-mode tests +// (where the real build-time embed is absent). +vi.mock('../../../../runtime-versions.js', () => { + const versions: Record = { + stash: '1.0.0-rc.2', + '@cipherstash/stack': '1.0.0-rc.2', + '@cipherstash/stack-supabase': '1.0.0-rc.2', + } + return { + expectedVersion: (pkg: string) => versions[pkg], + pinnedSpec: (pkg: string) => + versions[pkg] ? `${pkg}@${versions[pkg]}` : pkg, + } +}) // Toggle interactivity per test (defaults to interactive in beforeEach). vi.mock('../../../../config/tty.js', () => ({ isInteractive: vi.fn(() => true), @@ -31,8 +49,12 @@ vi.mock('@clack/prompts', () => ({ import * as p from '@clack/prompts' import { isInteractive } from '../../../../config/tty.js' -import { isPackageInstalled } from '../../utils.js' -import { installDepsStep } from '../install-deps.js' +import { + combinedInstallCommands, + installedVersion, + isPackageInstalled, +} from '../../utils.js' +import { installDepsStep, versionSkew } from '../install-deps.js' const baseState = {} as unknown as InitState const provider = { name: 'postgresql' } as unknown as InitProvider @@ -77,4 +99,78 @@ describe('installDepsStep', () => { expect(result.stackInstalled).toBe(true) expect(result.cliInstalled).toBe(true) }) + + it('pins fresh installs to the versions this release was built with (#661)', async () => { + // Missing at the gate, present on the post-install recheck. + let n = 0 + vi.mocked(isPackageInstalled).mockImplementation(() => ++n > 3) + + await installDepsStep.run(baseState, { + name: 'supabase', + } as unknown as InitProvider) + + const [, prod, dev] = vi.mocked(combinedInstallCommands).mock.calls[0] + expect(prod).toEqual([ + '@cipherstash/stack@1.0.0-rc.2', + '@cipherstash/stack-supabase@1.0.0-rc.2', + ]) + expect(dev).toEqual(['stash@1.0.0-rc.2']) + }) + + it('warns on version skew when packages are already installed (#661)', async () => { + vi.mocked(isPackageInstalled).mockReturnValue(true) + // The dist-tag failure mode: node_modules holds the stale 0.19.0. + vi.mocked(installedVersion).mockImplementation((pkg: string) => + pkg === '@cipherstash/stack' ? '0.19.0' : '1.0.0-rc.2', + ) + + await installDepsStep.run(baseState, provider) + + expect(execSyncMock).not.toHaveBeenCalled() + expect(p.log.warn).toHaveBeenCalledWith( + expect.stringContaining( + '@cipherstash/stack: installed 0.19.0, this release of stash expects 1.0.0-rc.2', + ), + ) + }) + + it('stays silent when installed versions match the release', async () => { + vi.mocked(isPackageInstalled).mockReturnValue(true) + vi.mocked(installedVersion).mockReturnValue('1.0.0-rc.2') + + await installDepsStep.run(baseState, provider) + + expect(p.log.warn).not.toHaveBeenCalled() + }) + + describe('versionSkew', () => { + it('reports only packages whose resolved version differs', () => { + vi.mocked(installedVersion).mockImplementation((pkg: string) => + pkg === '@cipherstash/stack' ? '0.19.0' : '1.0.0-rc.2', + ) + expect( + versionSkew(['@cipherstash/stack', 'stash'], { + '@cipherstash/stack': '1.0.0-rc.2', + stash: '1.0.0-rc.2', + }), + ).toEqual([ + { + pkg: '@cipherstash/stack', + installed: '0.19.0', + expected: '1.0.0-rc.2', + }, + ]) + }) + + it('reports nothing for absent packages or an absent release map', () => { + vi.mocked(installedVersion).mockReturnValue(undefined) + expect( + versionSkew(['@cipherstash/stack'], { + '@cipherstash/stack': '1.0.0-rc.2', + }), + ).toEqual([]) + vi.mocked(installedVersion).mockReturnValue('0.19.0') + expect(versionSkew(['@no-map/package'], {})).toEqual([]) + }) + }) }) diff --git a/packages/cli/src/commands/init/steps/install-deps.ts b/packages/cli/src/commands/init/steps/install-deps.ts index 574ee92ab..7c6fffc68 100644 --- a/packages/cli/src/commands/init/steps/install-deps.ts +++ b/packages/cli/src/commands/init/steps/install-deps.ts @@ -1,11 +1,13 @@ import { execSync } from 'node:child_process' import * as p from '@clack/prompts' import { isInteractive } from '../../../config/tty.js' +import { expectedVersion, pinnedSpec } from '../../../runtime-versions.js' import type { InitProvider, InitState, InitStep } from '../types.js' import { CancelledError } from '../types.js' import { combinedInstallCommands, detectPackageManager, + installedVersion, isPackageInstalled, } from '../utils.js' @@ -34,6 +36,49 @@ function integrationPackageFor(integration?: string): string | null { } } +/** + * Report packages whose installed (resolved, on-disk) version differs from + * the version this CLI release was built alongside. Skew like this is how the + * dist-tag failure mode (#661) stays invisible: the project's `^`-range spec + * looks fine while `node_modules` holds a stale `0.19.0` or placeholder + * `0.0.0`. Packages that are absent, or absent from the release map (source + * builds), report nothing. + */ +export function versionSkew( + packages: readonly string[], + versions?: Readonly>, +): Array<{ pkg: string; installed: string; expected: string }> { + const skewed: Array<{ pkg: string; installed: string; expected: string }> = [] + for (const pkg of packages) { + const expected = versions ? versions[pkg] : expectedVersion(pkg) + if (!expected) continue + const installed = installedVersion(pkg) + if (installed && installed !== expected) + skewed.push({ pkg, installed, expected }) + } + return skewed +} + +/** Warn (never mutate) when installed versions don't match this release. */ +function warnOnVersionSkew(packages: readonly string[]): void { + const skewed = versionSkew(packages) + if (skewed.length === 0) return + const pm = detectPackageManager() + const lines = skewed.map( + ({ pkg, installed, expected }) => + `${pkg}: installed ${installed}, this release of stash expects ${expected}`, + ) + p.log.warn(`Version skew detected:\n ${lines.join('\n ')}`) + p.note( + `Align them with:\n ${combinedInstallCommands( + pm, + skewed.map(({ pkg }) => pinnedSpec(pkg)), + [], + ).join('\n ')}`, + 'Version skew', + ) +} + /** * Install the runtime + dev npm packages the user needs to run encryption: * @@ -46,9 +91,16 @@ function integrationPackageFor(integration?: string): string | null { * - `stash` (dev) — the CLI itself, so the user can run `stash eql install`, * `stash wizard`, etc. as a project script without the global install. * - * Skips silently when everything is already present. Prompts before running the - * install commands so the user sees the package manager invocation that's - * about to execute. + * Installs are PINNED to the versions this CLI release was built alongside + * (see `src/runtime-versions.ts` and #661) — bare package names resolve + * through npm dist-tags, which lag or point at placeholders during + * pre-release windows and then deliver a different release than the CLI + * driving the setup. Already-present packages are left untouched, but a + * version that differs from this release's is called out loudly. + * + * Skips silently when everything is already present at matching versions. + * Prompts before running the install commands so the user sees the package + * manager invocation that's about to execute. */ export const installDepsStep: InitStep = { id: 'install-deps', @@ -63,20 +115,30 @@ export const installDepsStep: InitStep = { ? isPackageInstalled(integrationPkg) : true - // Everything already there — silent success, no prompts. + const allPackages = [ + STACK_PACKAGE, + ...(integrationPkg ? [integrationPkg] : []), + CLI_PACKAGE, + ] + + // Everything already there — leave it alone (no prompts), but surface + // version skew against this release rather than silently proceeding on a + // stale or placeholder install (#661). if (stackPresent && cliPresent && integrationPresent) { const installed = integrationPkg ? `${STACK_PACKAGE}, ${integrationPkg} and ${CLI_PACKAGE}` : `${STACK_PACKAGE} and ${CLI_PACKAGE}` p.log.success(`${installed} are already installed.`) + warnOnVersionSkew(allPackages) return { ...state, stackInstalled: true, cliInstalled: true } } const pm = detectPackageManager() const prodPackages: string[] = [] - if (!stackPresent) prodPackages.push(STACK_PACKAGE) - if (integrationPkg && !integrationPresent) prodPackages.push(integrationPkg) - const devPackages = cliPresent ? [] : [CLI_PACKAGE] + if (!stackPresent) prodPackages.push(pinnedSpec(STACK_PACKAGE)) + if (integrationPkg && !integrationPresent) + prodPackages.push(pinnedSpec(integrationPkg)) + const devPackages = cliPresent ? [] : [pinnedSpec(CLI_PACKAGE)] const commands = combinedInstallCommands(pm, prodPackages, devPackages) const missingList = [ @@ -140,6 +202,9 @@ export const installDepsStep: InitStep = { if (stackInstalled && cliInstalled && integrationInstalled) { p.log.success('Stack dependencies installed.') + // Fresh installs above are pinned, but packages that were ALREADY + // present were not touched — check the whole set for skew. + warnOnVersionSkew(allPackages) } else { const stillMissing = [ ...(stackInstalled ? [] : [`${STACK_PACKAGE} (prod)`]), diff --git a/packages/cli/src/commands/init/utils.ts b/packages/cli/src/commands/init/utils.ts index 3ccb3a277..98e6d9326 100644 --- a/packages/cli/src/commands/init/utils.ts +++ b/packages/cli/src/commands/init/utils.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import type { Integration, SchemaDef } from './types.js' @@ -19,6 +19,33 @@ export function isPackageInstalled(packageName: string): boolean { return existsSync(modulePath) && existsSync(manifestPath) } +/** + * The RESOLVED version of an installed package — what's actually on disk in + * `node_modules//package.json`, not the specifier in the project + * manifest. Used by init to detect version skew against the release this CLI + * shipped with (#661): a stale or placeholder install (`0.19.0` / `0.0.0` + * from a lagging dist-tag) hides behind a `^`-range spec but is obvious here. + * Returns `undefined` when the package (or its manifest version) is absent. + */ +export function installedVersion(packageName: string): string | undefined { + const manifestPath = resolve( + process.cwd(), + 'node_modules', + packageName, + 'package.json', + ) + if (!existsSync(manifestPath)) return undefined + try { + const manifest: unknown = JSON.parse(readFileSync(manifestPath, 'utf8')) + const version = (manifest as { version?: unknown }).version + return typeof version === 'string' && version.length > 0 + ? version + : undefined + } catch { + return undefined + } +} + export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun' /** diff --git a/packages/cli/src/config/missing-package.ts b/packages/cli/src/config/missing-package.ts index fc5665ec5..07f55ea0c 100644 --- a/packages/cli/src/config/missing-package.ts +++ b/packages/cli/src/config/missing-package.ts @@ -12,6 +12,7 @@ import { } from '../commands/init/utils.js' import { messages } from '../messages.js' import { isModuleNotFound, moduleNotFoundSpecifier } from '../module-error.js' +import { pinnedSpec } from '../runtime-versions.js' const CLI_PACKAGE = 'stash' const STACK_PACKAGE = '@cipherstash/stack' @@ -44,7 +45,11 @@ export function missingCipherStashPackage(error: unknown): string | undefined { export function reportMissingCipherStashPackage(pkg: string): never { const pm = detectPackageManager() const stash = runnerCommand(pm, 'stash') - const install = combinedInstallCommands(pm, [STACK_PACKAGE], [CLI_PACKAGE]) + const install = combinedInstallCommands( + pm, + [pinnedSpec(STACK_PACKAGE)], + [pinnedSpec(CLI_PACKAGE)], + ) console.error( `Error: ${messages.db.missingCipherStashPackage(pkg, install.join('\n '), stash)}\n`, ) diff --git a/packages/cli/src/runtime-versions.ts b/packages/cli/src/runtime-versions.ts new file mode 100644 index 000000000..62b7ee64b --- /dev/null +++ b/packages/cli/src/runtime-versions.ts @@ -0,0 +1,69 @@ +/** + * The exact versions of the CipherStash runtime packages this CLI release was + * built alongside, embedded at build time. + * + * Why this exists (#661): `stash init` used to install runtime packages + * unpinned (`npm install @cipherstash/stack`), which resolves whatever the + * `latest` dist-tag points at. During a pre-release window dist-tags lag or + * point at placeholders (`@cipherstash/stack-drizzle@latest` was the empty + * `0.0.0`; `@cipherstash/stack@latest` a stale `0.19.0`), so `init` silently + * delivered a *different release than the CLI running it* — broken `/v3` + * imports, and eval/agent runs that thought they were testing an rc while + * exercising the old stable. Pinning to the versions from this CLI's own + * release train makes `init` deterministic regardless of dist-tag state. + * + * The embed mirrors `__STASH_POSTHOG_KEY__` (see `src/telemetry/index.ts`): + * `tsup.config.ts` reads each sibling workspace package's `package.json` at + * build time and defines `__STASH_RUNTIME_VERSIONS__` as a JSON string. Every + * tsup build gets it (it needs no env var, unlike the PostHog key); only + * source-mode runs (unit tests, direct `tsx` execution) leave the identifier + * undefined, in which case {@link pinnedSpec} degrades to the bare package + * name — today's behaviour, and irrelevant to shipped artifacts. + */ +declare const __STASH_RUNTIME_VERSIONS__: string | undefined + +/** Parse and validate the build-time embed; empty map when absent/malformed. */ +function embeddedVersions(): Record { + if (typeof __STASH_RUNTIME_VERSIONS__ !== 'string') return {} + try { + const parsed: unknown = JSON.parse(__STASH_RUNTIME_VERSIONS__) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) + return {} + const map: Record = {} + for (const [pkg, version] of Object.entries(parsed)) { + if (typeof version === 'string' && version.length > 0) map[pkg] = version + } + return map + } catch { + return {} + } +} + +/** Package name → exact version from this CLI release's build. */ +export const RUNTIME_PACKAGE_VERSIONS: Readonly> = + embeddedVersions() + +/** + * The version of `pkg` this CLI release expects, or `undefined` when the + * package isn't part of the release train (or the build embed is absent). + */ +export function expectedVersion( + pkg: string, + versions: Readonly> = RUNTIME_PACKAGE_VERSIONS, +): string | undefined { + return versions[pkg] +} + +/** + * The install specifier for `pkg`, pinned to this release's version when + * known: `@cipherstash/stack` → `@cipherstash/stack@1.0.0-rc.2`. Falls back + * to the bare name when no version is embedded, preserving the old behaviour + * for source-mode runs. + */ +export function pinnedSpec( + pkg: string, + versions: Readonly> = RUNTIME_PACKAGE_VERSIONS, +): string { + const version = expectedVersion(pkg, versions) + return version ? `${pkg}@${version}` : pkg +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 2f17f343c..00ba1a2ac 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -1,4 +1,4 @@ -import { cpSync, existsSync } from 'node:fs' +import { cpSync, existsSync, readFileSync } from 'node:fs' import { defineConfig } from 'tsup' /** @@ -13,6 +13,45 @@ const posthogKeyDefine = { __STASH_POSTHOG_KEY__: JSON.stringify(process.env.STASH_POSTHOG_KEY ?? ''), } +/** + * Build-time embed of the runtime-package versions from this release train + * (see `src/runtime-versions.ts` and #661): `stash init` pins the packages it + * installs to the versions this CLI was built alongside, instead of trusting + * npm dist-tags (which lag, or point at placeholders, during pre-release + * windows). Versions are read straight from the sibling workspace manifests so + * the embed can never disagree with what Changesets is about to publish; a + * missing/broken manifest throws at build time — a silently absent embed would + * quietly reintroduce unpinned installs. Unlike the PostHog key this needs no + * env var, so EVERY build embeds it. The double `JSON.stringify` makes the + * define value a string literal that `runtime-versions.ts` JSON-parses. + */ +function workspaceVersion(relPkgJson: string): string { + const manifest: unknown = JSON.parse( + readFileSync(new URL(relPkgJson, import.meta.url), 'utf8'), + ) + const version = (manifest as { version?: unknown }).version + if (typeof version !== 'string' || version.length === 0) + throw new Error(`tsup: no version in ${relPkgJson}`) + return version +} +const runtimeVersionsDefine = { + __STASH_RUNTIME_VERSIONS__: JSON.stringify( + JSON.stringify({ + stash: workspaceVersion('./package.json'), + '@cipherstash/stack': workspaceVersion('../stack/package.json'), + '@cipherstash/stack-drizzle': workspaceVersion( + '../stack-drizzle/package.json', + ), + '@cipherstash/stack-supabase': workspaceVersion( + '../stack-supabase/package.json', + ), + '@cipherstash/prisma-next': workspaceVersion( + '../prisma-next/package.json', + ), + }), + ), +} + export default defineConfig([ { entry: ['src/index.ts'], @@ -29,7 +68,11 @@ export default defineConfig([ ...options.logOverride, 'empty-import-meta': 'silent', } - options.define = { ...options.define, ...posthogKeyDefine } + options.define = { + ...options.define, + ...posthogKeyDefine, + ...runtimeVersionsDefine, + } }, onSuccess: async () => { // Copy bundled SQL files into dist so they ship with the package @@ -66,7 +109,11 @@ var require = __createRequire(import.meta.url);`, skipNodeModulesBundle: true, esbuildOptions(options) { - options.define = { ...options.define, ...posthogKeyDefine } + options.define = { + ...options.define, + ...posthogKeyDefine, + ...runtimeVersionsDefine, + } }, }, ]) diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index c7103f5fe..74f1a4adb 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -36,7 +36,7 @@ npx stash init # PostgreSQL / Drizzle / Prisma npx stash init --supabase # Supabase ``` -`stash init` installs the CLI as a project dev dependency, so subsequent commands can drop the `npx`. The CLI is package-manager aware — before init, use whichever one-shot runner your project uses (`npx`, `pnpm dlx`, `bunx`, `yarn dlx`). +`stash init` installs the CLI as a project dev dependency, so subsequent commands can drop the `npx`. The CLI is package-manager aware — before init, use whichever one-shot runner your project uses (`npx`, `pnpm dlx`, `bunx`, `yarn dlx`). Installs are **pinned to the exact `@cipherstash/*` versions this CLI release shipped with** (never bare dist-tags, which can lag behind a release), and init warns if an already-installed package's resolved version differs from the release's — treat that warning as a real problem and align versions before continuing. **If you are an agent, do this first:** From 422410a6beb3cfac3f5f8224833226e20619c655 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 16 Jul 2026 15:39:56 +1000 Subject: [PATCH 2/3] test(cli): make runtime-version fixtures unmistakably fake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mocked version maps used realistic values (1.0.0-rc.2), which read as if they must track an actual release. They are arbitrary fixtures — the tests assert the map is threaded through verbatim; production values come from the workspace manifests at build time, never from constants. Use 9.9.9-test.N so no one mistakes them for release-coupled values, and say so in comments. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .../src/__tests__/runtime-versions.test.ts | 16 +++++---- .../init/steps/__tests__/install-deps.test.ts | 33 ++++++++++--------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-versions.test.ts b/packages/cli/src/__tests__/runtime-versions.test.ts index d1230b399..c8b7da9d4 100644 --- a/packages/cli/src/__tests__/runtime-versions.test.ts +++ b/packages/cli/src/__tests__/runtime-versions.test.ts @@ -23,20 +23,24 @@ describe('runtime-versions (no build-time embed)', () => { }) 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: '1.0.0-rc.2', - '@cipherstash/stack': '1.0.0-rc.2', - '@cipherstash/prisma-next': '0.4.0-rc.2', + 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@1.0.0-rc.2', + '@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@0.4.0-rc.2', + '@cipherstash/prisma-next@8.8.8-test.2', ) }) @@ -47,7 +51,7 @@ describe('runtime-versions (explicit version map)', () => { }) it('expectedVersion reads the map', () => { - expect(expectedVersion('stash', versions)).toBe('1.0.0-rc.2') + expect(expectedVersion('stash', versions)).toBe('9.9.9-test.1') expect(expectedVersion('nope', versions)).toBeUndefined() }) }) diff --git a/packages/cli/src/commands/init/steps/__tests__/install-deps.test.ts b/packages/cli/src/commands/init/steps/__tests__/install-deps.test.ts index 73f06a53b..646615512 100644 --- a/packages/cli/src/commands/init/steps/__tests__/install-deps.test.ts +++ b/packages/cli/src/commands/init/steps/__tests__/install-deps.test.ts @@ -17,12 +17,15 @@ vi.mock('../../utils.js', () => ({ })) // Pin map: pretend this CLI release was built alongside these versions, so // the pinned-spec and skew paths are exercisable from source-mode tests -// (where the real build-time embed is absent). +// (where the real build-time embed is absent). The versions are arbitrary +// FIXTURES (deliberately unreal, so nobody mistakes them for values that must +// track a release) — production values come from the workspace manifests at +// build time, never from constants. vi.mock('../../../../runtime-versions.js', () => { const versions: Record = { - stash: '1.0.0-rc.2', - '@cipherstash/stack': '1.0.0-rc.2', - '@cipherstash/stack-supabase': '1.0.0-rc.2', + stash: '9.9.9-test.1', + '@cipherstash/stack': '9.9.9-test.1', + '@cipherstash/stack-supabase': '9.9.9-test.1', } return { expectedVersion: (pkg: string) => versions[pkg], @@ -111,17 +114,17 @@ describe('installDepsStep', () => { const [, prod, dev] = vi.mocked(combinedInstallCommands).mock.calls[0] expect(prod).toEqual([ - '@cipherstash/stack@1.0.0-rc.2', - '@cipherstash/stack-supabase@1.0.0-rc.2', + '@cipherstash/stack@9.9.9-test.1', + '@cipherstash/stack-supabase@9.9.9-test.1', ]) - expect(dev).toEqual(['stash@1.0.0-rc.2']) + expect(dev).toEqual(['stash@9.9.9-test.1']) }) it('warns on version skew when packages are already installed (#661)', async () => { vi.mocked(isPackageInstalled).mockReturnValue(true) // The dist-tag failure mode: node_modules holds the stale 0.19.0. vi.mocked(installedVersion).mockImplementation((pkg: string) => - pkg === '@cipherstash/stack' ? '0.19.0' : '1.0.0-rc.2', + pkg === '@cipherstash/stack' ? '0.19.0' : '9.9.9-test.1', ) await installDepsStep.run(baseState, provider) @@ -129,14 +132,14 @@ describe('installDepsStep', () => { expect(execSyncMock).not.toHaveBeenCalled() expect(p.log.warn).toHaveBeenCalledWith( expect.stringContaining( - '@cipherstash/stack: installed 0.19.0, this release of stash expects 1.0.0-rc.2', + '@cipherstash/stack: installed 0.19.0, this release of stash expects 9.9.9-test.1', ), ) }) it('stays silent when installed versions match the release', async () => { vi.mocked(isPackageInstalled).mockReturnValue(true) - vi.mocked(installedVersion).mockReturnValue('1.0.0-rc.2') + vi.mocked(installedVersion).mockReturnValue('9.9.9-test.1') await installDepsStep.run(baseState, provider) @@ -146,18 +149,18 @@ describe('installDepsStep', () => { describe('versionSkew', () => { it('reports only packages whose resolved version differs', () => { vi.mocked(installedVersion).mockImplementation((pkg: string) => - pkg === '@cipherstash/stack' ? '0.19.0' : '1.0.0-rc.2', + pkg === '@cipherstash/stack' ? '0.19.0' : '9.9.9-test.1', ) expect( versionSkew(['@cipherstash/stack', 'stash'], { - '@cipherstash/stack': '1.0.0-rc.2', - stash: '1.0.0-rc.2', + '@cipherstash/stack': '9.9.9-test.1', + stash: '9.9.9-test.1', }), ).toEqual([ { pkg: '@cipherstash/stack', installed: '0.19.0', - expected: '1.0.0-rc.2', + expected: '9.9.9-test.1', }, ]) }) @@ -166,7 +169,7 @@ describe('installDepsStep', () => { vi.mocked(installedVersion).mockReturnValue(undefined) expect( versionSkew(['@cipherstash/stack'], { - '@cipherstash/stack': '1.0.0-rc.2', + '@cipherstash/stack': '9.9.9-test.1', }), ).toEqual([]) vi.mocked(installedVersion).mockReturnValue('0.19.0') From 67dcd0b562016d830331b73328fedcec4e3baa25 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 16 Jul 2026 16:36:59 +1000 Subject: [PATCH 3/3] =?UTF-8?q?fix(cli):=20harden=20init=20pinning=20per?= =?UTF-8?q?=20review=20=E2=80=94=20skew=20coverage,=20dev=20split,=20fail-?= =?UTF-8?q?loud=20embed,=20remaining=20unpinned=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the code-review findings on #666 plus reviewer comments (auxesis, Copilot, CodeRabbit): - Skew is surfaced BEFORE any prompt/exit, so decline and partial-failure paths can no longer skip it; interactively init offers alignment in the same confirm (stash stays in the DEV list — the align command previously put every skewed package in the prod slot, which npm/yarn-classic would use to move the CLI into dependencies). - An installed package with an unreadable manifest (aborted install) is reported as skew instead of silently passing the check. - A present-but-malformed version embed now THROWS instead of degrading to unpinned installs; parseEmbeddedVersions is exported and unit-tested, and a new e2e asserts the BUILT bundles carry the workspace versions (the behavioural guidance path is unreachable under the harness — jiti resolves the monorepo's own node_modules). - Release-train list is now a single shared source (src/release-train.ts) consumed by tsup and cross-checked by a unit test against INTEGRATION_ADAPTER_PACKAGES — a future adapter missing from the train fails tests instead of installing unpinned and skew-exempt. - Remaining unpinned surfaces pinned: `stash wizard` one-shot spawn (was executing bare @cipherstash/wizard; now on the train), install-eql's manual note, native.ts recovery hint (was stash@latest). - Sibling skills' bare install commands annotated with the pinning / verify-resolved guidance (stash-encryption, -supabase, -drizzle, -dynamodb). - New tests: parseEmbeddedVersions matrix, real-filesystem installedVersion, reportMissingCipherStashPackage pinned output, release-train coverage, skew decline/non-interactive/unreadable paths, dist embed e2e. Not addressed here (discussion): the release-train coupling itself — a stack-only release post-GA would leave the published stash embed stale and the skew warning advising downgrades. Needs a changesets fixed/linked group or an internal dep edge; tracked on the PR. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .changeset/init-pins-runtime-versions.md | 26 +- .../cli/src/__tests__/release-train.test.ts | 49 ++++ .../src/__tests__/runtime-versions.test.ts | 43 +++- .../src/commands/init/__tests__/utils.test.ts | 51 ++++ .../init/steps/__tests__/install-deps.test.ts | 241 +++++++++++++----- .../src/commands/init/steps/install-deps.ts | 221 ++++++++++------ .../src/commands/init/steps/install-eql.ts | 6 +- packages/cli/src/commands/wizard/index.ts | 7 +- .../config/__tests__/missing-package.test.ts | 43 +++- packages/cli/src/native.ts | 7 +- packages/cli/src/release-train.ts | 27 ++ packages/cli/src/runtime-versions.ts | 55 ++-- .../e2e/runtime-versions-embed.e2e.test.ts | 57 +++++ packages/cli/tsup.config.ts | 53 ++-- skills/stash-drizzle/SKILL.md | 7 + skills/stash-dynamodb/SKILL.md | 7 + skills/stash-encryption/SKILL.md | 7 + skills/stash-supabase/SKILL.md | 7 + 18 files changed, 713 insertions(+), 201 deletions(-) create mode 100644 packages/cli/src/__tests__/release-train.test.ts create mode 100644 packages/cli/src/release-train.ts create mode 100644 packages/cli/tests/e2e/runtime-versions-embed.e2e.test.ts diff --git a/.changeset/init-pins-runtime-versions.md b/.changeset/init-pins-runtime-versions.md index ac84b3eeb..148977d26 100644 --- a/.changeset/init-pins-runtime-versions.md +++ b/.changeset/init-pins-runtime-versions.md @@ -9,13 +9,21 @@ 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, so they can -never disagree with what was published together. +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 **warns on version skew**: if a `@cipherstash/*` package is -already installed but its resolved `node_modules` version differs from the one -this release expects, init says so and prints the exact command to align it -(it never mutates existing installs). The install guidance printed by other -commands (missing-package hints, `.cipherstash/context.json`'s -`installCommand`) is pinned the same way, and the `stash-cli` skill documents -the pinning and skew-warning behaviour. +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. diff --git a/packages/cli/src/__tests__/release-train.test.ts b/packages/cli/src/__tests__/release-train.test.ts new file mode 100644 index 000000000..36dafd40b --- /dev/null +++ b/packages/cli/src/__tests__/release-train.test.ts @@ -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) + } + }) +}) diff --git a/packages/cli/src/__tests__/runtime-versions.test.ts b/packages/cli/src/__tests__/runtime-versions.test.ts index c8b7da9d4..a3f3b6684 100644 --- a/packages/cli/src/__tests__/runtime-versions.test.ts +++ b/packages/cli/src/__tests__/runtime-versions.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { expectedVersion, + parseEmbeddedVersions, pinnedSpec, RUNTIME_PACKAGE_VERSIONS, } from '../runtime-versions.js' @@ -22,6 +23,44 @@ describe('runtime-versions (no build-time embed)', () => { }) }) +// 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. @@ -45,8 +84,8 @@ describe('runtime-versions (explicit version map)', () => { }) it('leaves unknown packages unpinned', () => { - expect(pinnedSpec('@cipherstash/stack-drizzle', versions)).toBe( - '@cipherstash/stack-drizzle', + expect(pinnedSpec('@cipherstash/not-on-the-train', versions)).toBe( + '@cipherstash/not-on-the-train', ) }) diff --git a/packages/cli/src/commands/init/__tests__/utils.test.ts b/packages/cli/src/commands/init/__tests__/utils.test.ts index 704dec349..e76257b83 100644 --- a/packages/cli/src/commands/init/__tests__/utils.test.ts +++ b/packages/cli/src/commands/init/__tests__/utils.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { detectPackageManager, devInstallCommand, + installedVersion, isPackageInstalled, prodInstallCommand, runnerCommand, @@ -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 | 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() + }) +}) diff --git a/packages/cli/src/commands/init/steps/__tests__/install-deps.test.ts b/packages/cli/src/commands/init/steps/__tests__/install-deps.test.ts index 646615512..3564fe7c1 100644 --- a/packages/cli/src/commands/init/steps/__tests__/install-deps.test.ts +++ b/packages/cli/src/commands/init/steps/__tests__/install-deps.test.ts @@ -21,18 +21,22 @@ vi.mock('../../utils.js', () => ({ // FIXTURES (deliberately unreal, so nobody mistakes them for values that must // track a release) — production values come from the workspace manifests at // build time, never from constants. -vi.mock('../../../../runtime-versions.js', () => { - const versions: Record = { - stash: '9.9.9-test.1', - '@cipherstash/stack': '9.9.9-test.1', - '@cipherstash/stack-supabase': '9.9.9-test.1', - } - return { - expectedVersion: (pkg: string) => versions[pkg], - pinnedSpec: (pkg: string) => - versions[pkg] ? `${pkg}@${versions[pkg]}` : pkg, - } -}) +const FIXTURE_VERSIONS: Record = vi.hoisted(() => ({ + stash: '9.9.9-test.1', + '@cipherstash/stack': '9.9.9-test.1', + '@cipherstash/stack-supabase': '9.9.9-test.1', +})) +vi.mock('../../../../runtime-versions.js', () => ({ + RUNTIME_PACKAGE_VERSIONS: FIXTURE_VERSIONS, + expectedVersion: ( + pkg: string, + versions: Record = FIXTURE_VERSIONS, + ): string | undefined => versions[pkg], + pinnedSpec: ( + pkg: string, + versions: Record = FIXTURE_VERSIONS, + ): string => (versions[pkg] ? `${pkg}@${versions[pkg]}` : pkg), +})) // Toggle interactivity per test (defaults to interactive in beforeEach). vi.mock('../../../../config/tty.js', () => ({ isInteractive: vi.fn(() => true), @@ -61,18 +65,47 @@ import { installDepsStep, versionSkew } from '../install-deps.js' const baseState = {} as unknown as InitState const provider = { name: 'postgresql' } as unknown as InitProvider +const supabaseProvider = { name: 'supabase' } as unknown as InitProvider + +/** Presence by package name — clearer and more robust than call counters. */ +function present(...pkgs: string[]) { + vi.mocked(isPackageInstalled).mockImplementation((pkg: string) => + pkgs.includes(pkg), + ) +} + +/** Resolved on-disk versions by package name. */ +function resolvedVersions(map: Record) { + vi.mocked(installedVersion).mockImplementation((pkg: string) => map[pkg]) +} + +/** The combinedInstallCommands call that actually built install commands + * (skips the always-made align-commands call, which may have empty lists). */ +function installCall(): [string, string[], string[]] { + const calls = vi.mocked(combinedInstallCommands).mock.calls as Array< + [string, string[], string[]] + > + const nonEmpty = calls.filter(([, prod, dev]) => prod.length + dev.length > 0) + expect(nonEmpty.length).toBeGreaterThan(0) + return nonEmpty[nonEmpty.length - 1] +} describe('installDepsStep', () => { beforeEach(() => { vi.clearAllMocks() vi.mocked(isInteractive).mockReturnValue(true) - vi.mocked(isPackageInstalled).mockReturnValue(false) + present() // nothing installed + resolvedVersions({}) }) it('prompts before installing when interactive', async () => { // Missing at the gate, present on the post-install recheck. - let n = 0 - vi.mocked(isPackageInstalled).mockImplementation(() => ++n > 2) + let installed = false + vi.mocked(isPackageInstalled).mockImplementation(() => installed) + execSyncMock.mockImplementation(() => { + installed = true + return '' + }) await installDepsStep.run(baseState, provider) @@ -82,8 +115,6 @@ describe('installDepsStep', () => { it('installs without prompting when non-interactive (#600)', async () => { vi.mocked(isInteractive).mockReturnValue(false) - let n = 0 - vi.mocked(isPackageInstalled).mockImplementation(() => ++n > 2) await installDepsStep.run(baseState, provider) @@ -92,27 +123,26 @@ describe('installDepsStep', () => { expect(execSyncMock).toHaveBeenCalled() }) - it('skips silently when everything is already installed (no prompt)', async () => { - vi.mocked(isPackageInstalled).mockReturnValue(true) + it('skips silently when everything is installed at matching versions', async () => { + present('@cipherstash/stack', 'stash') + resolvedVersions({ + '@cipherstash/stack': FIXTURE_VERSIONS['@cipherstash/stack'], + stash: FIXTURE_VERSIONS.stash, + }) const result = await installDepsStep.run(baseState, provider) expect(p.confirm).not.toHaveBeenCalled() expect(execSyncMock).not.toHaveBeenCalled() + expect(p.log.warn).not.toHaveBeenCalled() expect(result.stackInstalled).toBe(true) expect(result.cliInstalled).toBe(true) }) it('pins fresh installs to the versions this release was built with (#661)', async () => { - // Missing at the gate, present on the post-install recheck. - let n = 0 - vi.mocked(isPackageInstalled).mockImplementation(() => ++n > 3) - - await installDepsStep.run(baseState, { - name: 'supabase', - } as unknown as InitProvider) + await installDepsStep.run(baseState, supabaseProvider) - const [, prod, dev] = vi.mocked(combinedInstallCommands).mock.calls[0] + const [, prod, dev] = installCall() expect(prod).toEqual([ '@cipherstash/stack@9.9.9-test.1', '@cipherstash/stack-supabase@9.9.9-test.1', @@ -120,60 +150,143 @@ describe('installDepsStep', () => { expect(dev).toEqual(['stash@9.9.9-test.1']) }) - it('warns on version skew when packages are already installed (#661)', async () => { - vi.mocked(isPackageInstalled).mockReturnValue(true) - // The dist-tag failure mode: node_modules holds the stale 0.19.0. - vi.mocked(installedVersion).mockImplementation((pkg: string) => - pkg === '@cipherstash/stack' ? '0.19.0' : '9.9.9-test.1', - ) + it('warns on version skew and aligns with the dev/prod split intact (#661)', async () => { + // The dist-tag failure mode: node_modules holds stale versions of both + // the runtime package (prod) and the CLI (dev). + present('@cipherstash/stack', 'stash') + resolvedVersions({ '@cipherstash/stack': '0.19.0', stash: '0.19.0' }) await installDepsStep.run(baseState, provider) - expect(execSyncMock).not.toHaveBeenCalled() expect(p.log.warn).toHaveBeenCalledWith( expect.stringContaining( '@cipherstash/stack: installed 0.19.0, this release of stash expects 9.9.9-test.1', ), ) + // Accepted (confirm mock defaults to true) → aligned with `stash` in the + // DEV list, not prod: the align command must not reclassify the CLI as a + // runtime dependency. + const [, prod, dev] = installCall() + expect(prod).toEqual(['@cipherstash/stack@9.9.9-test.1']) + expect(dev).toEqual(['stash@9.9.9-test.1']) + expect(execSyncMock).toHaveBeenCalled() }) - it('stays silent when installed versions match the release', async () => { - vi.mocked(isPackageInstalled).mockReturnValue(true) - vi.mocked(installedVersion).mockReturnValue('9.9.9-test.1') + it('still warns on skew when the user declines the install', async () => { + present('@cipherstash/stack', 'stash') + resolvedVersions({ + '@cipherstash/stack': '0.19.0', + stash: FIXTURE_VERSIONS.stash, + }) + vi.mocked(p.confirm).mockResolvedValueOnce(false) await installDepsStep.run(baseState, provider) - expect(p.log.warn).not.toHaveBeenCalled() + // The warning precedes the prompt, so declining cannot skip it. + expect(p.log.warn).toHaveBeenCalledWith( + expect.stringContaining('@cipherstash/stack: installed 0.19.0'), + ) + expect(execSyncMock).not.toHaveBeenCalled() + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('@cipherstash/stack@9.9.9-test.1'), + 'Manual Installation', + ) }) - describe('versionSkew', () => { - it('reports only packages whose resolved version differs', () => { - vi.mocked(installedVersion).mockImplementation((pkg: string) => - pkg === '@cipherstash/stack' ? '0.19.0' : '9.9.9-test.1', - ) - expect( - versionSkew(['@cipherstash/stack', 'stash'], { - '@cipherstash/stack': '9.9.9-test.1', - stash: '9.9.9-test.1', - }), - ).toEqual([ - { - pkg: '@cipherstash/stack', - installed: '0.19.0', - expected: '9.9.9-test.1', - }, - ]) + it('non-interactive: warns on skew, prints align commands, never mutates', async () => { + vi.mocked(isInteractive).mockReturnValue(false) + present('@cipherstash/stack', 'stash') + resolvedVersions({ + '@cipherstash/stack': '0.19.0', + stash: FIXTURE_VERSIONS.stash, }) - it('reports nothing for absent packages or an absent release map', () => { - vi.mocked(installedVersion).mockReturnValue(undefined) - expect( - versionSkew(['@cipherstash/stack'], { - '@cipherstash/stack': '9.9.9-test.1', - }), - ).toEqual([]) - vi.mocked(installedVersion).mockReturnValue('0.19.0') - expect(versionSkew(['@no-map/package'], {})).toEqual([]) + const result = await installDepsStep.run(baseState, provider) + + expect(p.log.warn).toHaveBeenCalledWith( + expect.stringContaining('@cipherstash/stack: installed 0.19.0'), + ) + // The note carries the exact pinned align command (the changeset promise). + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('npm install @cipherstash/stack@9.9.9-test.1'), + 'Version skew', + ) + expect(execSyncMock).not.toHaveBeenCalled() + expect(result.stackInstalled).toBe(true) + expect(result.cliInstalled).toBe(true) + }) + + it('reports an unreadable manifest as skew, not as a matching install', async () => { + vi.mocked(isInteractive).mockReturnValue(false) + present('@cipherstash/stack', 'stash') + // Aborted install: directory + manifest exist, but the manifest is + // corrupt so installedVersion cannot read it. + resolvedVersions({ + '@cipherstash/stack': undefined, + stash: FIXTURE_VERSIONS.stash, }) + + await installDepsStep.run(baseState, provider) + + expect(p.log.warn).toHaveBeenCalledWith( + expect.stringContaining( + '@cipherstash/stack: installed unknown (unreadable package.json)', + ), + ) + }) +}) + +describe('versionSkew', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reports only installed packages whose resolved version differs', () => { + present('@cipherstash/stack', 'stash') + resolvedVersions({ + '@cipherstash/stack': '0.19.0', + stash: '9.9.9-test.1', + }) + expect( + versionSkew(['@cipherstash/stack', 'stash'], { + '@cipherstash/stack': '9.9.9-test.1', + stash: '9.9.9-test.1', + }), + ).toEqual([ + { + pkg: '@cipherstash/stack', + installed: '0.19.0', + expected: '9.9.9-test.1', + }, + ]) + }) + + it('reports nothing for absent packages or packages off the release map', () => { + present() // nothing installed + resolvedVersions({ '@cipherstash/stack': '0.19.0' }) + expect( + versionSkew(['@cipherstash/stack'], { + '@cipherstash/stack': '9.9.9-test.1', + }), + ).toEqual([]) + + present('@no-map/package') + expect(versionSkew(['@no-map/package'], {})).toEqual([]) + }) + + it('flags an installed package with an unreadable manifest', () => { + present('@cipherstash/stack') + resolvedVersions({ '@cipherstash/stack': undefined }) + expect( + versionSkew(['@cipherstash/stack'], { + '@cipherstash/stack': '9.9.9-test.1', + }), + ).toEqual([ + { + pkg: '@cipherstash/stack', + installed: 'unknown (unreadable package.json)', + expected: '9.9.9-test.1', + }, + ]) }) }) diff --git a/packages/cli/src/commands/init/steps/install-deps.ts b/packages/cli/src/commands/init/steps/install-deps.ts index 7c6fffc68..f4f4ac7ed 100644 --- a/packages/cli/src/commands/init/steps/install-deps.ts +++ b/packages/cli/src/commands/init/steps/install-deps.ts @@ -1,7 +1,11 @@ import { execSync } from 'node:child_process' import * as p from '@clack/prompts' import { isInteractive } from '../../../config/tty.js' -import { expectedVersion, pinnedSpec } from '../../../runtime-versions.js' +import { + expectedVersion, + pinnedSpec, + RUNTIME_PACKAGE_VERSIONS, +} from '../../../runtime-versions.js' import type { InitProvider, InitState, InitStep } from '../types.js' import { CancelledError } from '../types.js' import { @@ -13,27 +17,38 @@ import { const STACK_PACKAGE = '@cipherstash/stack' const CLI_PACKAGE = 'stash' -const PRISMA_NEXT_PACKAGE = '@cipherstash/prisma-next' -const DRIZZLE_PACKAGE = '@cipherstash/stack-drizzle' -const SUPABASE_PACKAGE = '@cipherstash/stack-supabase' /** * The integration adapter is its OWN package (depends on `@cipherstash/stack`), * not a subpath of it — so whichever integration the user picked, its adapter * package must be installed too, or the scaffolded client code (which imports * e.g. `@cipherstash/stack-drizzle`) fails to resolve. + * + * Exported so a unit test can assert every adapter is a key of + * `RELEASE_TRAIN_MANIFESTS` (`src/release-train.ts`) — an adapter added here + * but not to the release train would install unpinned and be invisible to the + * skew warning, silently reintroducing #661 for exactly the newest package. */ +export const INTEGRATION_ADAPTER_PACKAGES: Readonly> = { + 'prisma-next': '@cipherstash/prisma-next', + drizzle: '@cipherstash/stack-drizzle', + supabase: '@cipherstash/stack-supabase', +} + function integrationPackageFor(integration?: string): string | null { - switch (integration) { - case 'prisma-next': - return PRISMA_NEXT_PACKAGE - case 'drizzle': - return DRIZZLE_PACKAGE - case 'supabase': - return SUPABASE_PACKAGE - default: - return null - } + if (!integration) return null + return INTEGRATION_ADAPTER_PACKAGES[integration] ?? null +} + +/** Sentinel shown when a package directory exists but its manifest can't be + * read — a broken state worth surfacing, not skipping (aborted installs leave + * exactly this behind). */ +const UNREADABLE_VERSION = 'unknown (unreadable package.json)' + +export type VersionSkewEntry = { + pkg: string + installed: string + expected: string } /** @@ -41,42 +56,49 @@ function integrationPackageFor(integration?: string): string | null { * the version this CLI release was built alongside. Skew like this is how the * dist-tag failure mode (#661) stays invisible: the project's `^`-range spec * looks fine while `node_modules` holds a stale `0.19.0` or placeholder - * `0.0.0`. Packages that are absent, or absent from the release map (source - * builds), report nothing. + * `0.0.0`. A package that is present but has an unreadable manifest is + * reported too (as {@link UNREADABLE_VERSION}) — that is a broken install, + * not a matching one. Packages that are absent, or absent from the release + * map (source builds), report nothing. */ export function versionSkew( packages: readonly string[], - versions?: Readonly>, -): Array<{ pkg: string; installed: string; expected: string }> { - const skewed: Array<{ pkg: string; installed: string; expected: string }> = [] + versions: Readonly> = RUNTIME_PACKAGE_VERSIONS, +): VersionSkewEntry[] { + const skewed: VersionSkewEntry[] = [] for (const pkg of packages) { - const expected = versions ? versions[pkg] : expectedVersion(pkg) + const expected = expectedVersion(pkg, versions) if (!expected) continue - const installed = installedVersion(pkg) - if (installed && installed !== expected) - skewed.push({ pkg, installed, expected }) + if (!isPackageInstalled(pkg)) continue + const installed = installedVersion(pkg) ?? UNREADABLE_VERSION + if (installed !== expected) skewed.push({ pkg, installed, expected }) } return skewed } -/** Warn (never mutate) when installed versions don't match this release. */ -function warnOnVersionSkew(packages: readonly string[]): void { - const skewed = versionSkew(packages) - if (skewed.length === 0) return - const pm = detectPackageManager() - const lines = skewed.map( - ({ pkg, installed, expected }) => - `${pkg}: installed ${installed}, this release of stash expects ${expected}`, - ) - p.log.warn(`Version skew detected:\n ${lines.join('\n ')}`) - p.note( - `Align them with:\n ${combinedInstallCommands( - pm, - skewed.map(({ pkg }) => pinnedSpec(pkg)), - [], - ).join('\n ')}`, - 'Version skew', - ) +/** Render one `pkg: installed X, this release of stash expects Y` line per + * skewed package. */ +function skewLines(skewed: readonly VersionSkewEntry[]): string { + return skewed + .map( + ({ pkg, installed, expected }) => + `${pkg}: installed ${installed}, this release of stash expects ${expected}`, + ) + .join('\n ') +} + +/** Split pinned install specs into (prod, dev) lists — `stash` is a dev + * dependency by init's own convention; everything else is prod. */ +function splitProdDev(packages: readonly string[]): { + prod: string[] + dev: string[] +} { + const prod: string[] = [] + const dev: string[] = [] + for (const pkg of packages) { + ;(pkg === CLI_PACKAGE ? dev : prod).push(pinnedSpec(pkg)) + } + return { prod, dev } } /** @@ -84,10 +106,8 @@ function warnOnVersionSkew(packages: readonly string[]): void { * * - `@cipherstash/stack` (prod) — the encryption client, schema builders, and * EQL v3 typed client. - * - the integration adapter package (prod), if the chosen integration has one: - * `@cipherstash/stack-drizzle`, `@cipherstash/stack-supabase`, or - * `@cipherstash/prisma-next`. These are separate packages that depend on - * `@cipherstash/stack`. + * - the integration adapter package (prod), if the chosen integration has one + * (see {@link INTEGRATION_ADAPTER_PACKAGES}). * - `stash` (dev) — the CLI itself, so the user can run `stash eql install`, * `stash wizard`, etc. as a project script without the global install. * @@ -95,12 +115,18 @@ function warnOnVersionSkew(packages: readonly string[]): void { * (see `src/runtime-versions.ts` and #661) — bare package names resolve * through npm dist-tags, which lag or point at placeholders during * pre-release windows and then deliver a different release than the CLI - * driving the setup. Already-present packages are left untouched, but a - * version that differs from this release's is called out loudly. + * driving the setup. * - * Skips silently when everything is already present at matching versions. - * Prompts before running the install commands so the user sees the package - * manager invocation that's about to execute. + * Version skew on ALREADY-INSTALLED packages is surfaced unconditionally, + * before any prompt or early exit, so no path (decline, partial failure, + * everything-already-present) proceeds silently on a stale or placeholder + * install. Interactively, init offers to align the skewed packages to this + * release in the same confirm as the missing installs; non-interactively it + * NEVER mutates an existing install — it warns, prints the exact align + * commands, and proceeds. + * + * When everything is already present at matching versions this logs a + * success line and moves on with no prompts. */ export const installDepsStep: InitStep = { id: 'install-deps', @@ -121,40 +147,82 @@ export const installDepsStep: InitStep = { CLI_PACKAGE, ] - // Everything already there — leave it alone (no prompts), but surface - // version skew against this release rather than silently proceeding on a - // stale or placeholder install (#661). - if (stackPresent && cliPresent && integrationPresent) { - const installed = integrationPkg - ? `${STACK_PACKAGE}, ${integrationPkg} and ${CLI_PACKAGE}` - : `${STACK_PACKAGE} and ${CLI_PACKAGE}` - p.log.success(`${installed} are already installed.`) - warnOnVersionSkew(allPackages) + // Surface skew FIRST and unconditionally — before any prompt, decline, + // failure, or early return can skip it (#661). Every path below inherits + // this warning. + const pm = detectPackageManager() + const skewed = versionSkew(allPackages) + const alignSplit = splitProdDev(skewed.map(({ pkg }) => pkg)) + const alignCommands = combinedInstallCommands( + pm, + alignSplit.prod, + alignSplit.dev, + ) + if (skewed.length > 0) { + p.log.warn(`Version skew detected:\n ${skewLines(skewed)}`) + } + + // What's missing outright (pinned, prod/dev split). + const missing: string[] = [] + if (!stackPresent) missing.push(STACK_PACKAGE) + if (integrationPkg && !integrationPresent) missing.push(integrationPkg) + if (!cliPresent) missing.push(CLI_PACKAGE) + const missingSplit = splitProdDev(missing) + + // Interactively, skewed packages can be aligned in the same install run. + // Non-interactive runs never mutate an existing install: agents/CI get + // the warning + exact commands above and keep going. + const offerAlign = skewed.length > 0 && isInteractive() + + // Nothing missing and no interactive alignment to offer: `missing` empty + // implies all three packages are present, so both flags are true. + if (missing.length === 0 && !offerAlign) { + if (skewed.length === 0) { + const installed = integrationPkg + ? `${STACK_PACKAGE}, ${integrationPkg} and ${CLI_PACKAGE}` + : `${STACK_PACKAGE} and ${CLI_PACKAGE}` + p.log.success(`${installed} are already installed.`) + } else { + // Non-interactive with skew: warned above; never mutate, print the fix. + p.note( + `Not changing installed packages (non-interactive). Align manually with:\n ${alignCommands.join('\n ')}`, + 'Version skew', + ) + } return { ...state, stackInstalled: true, cliInstalled: true } } - const pm = detectPackageManager() - const prodPackages: string[] = [] - if (!stackPresent) prodPackages.push(pinnedSpec(STACK_PACKAGE)) - if (integrationPkg && !integrationPresent) - prodPackages.push(pinnedSpec(integrationPkg)) - const devPackages = cliPresent ? [] : [pinnedSpec(CLI_PACKAGE)] - const commands = combinedInstallCommands(pm, prodPackages, devPackages) + const prodPackages = [...missingSplit.prod] + const devPackages = [...missingSplit.dev] const missingList = [ - ...prodPackages.map((pkg) => `${pkg} (prod)`), - ...devPackages.map((pkg) => `${pkg} (dev)`), + ...missingSplit.prod.map((pkg) => `${pkg} (prod)`), + ...missingSplit.dev.map((pkg) => `${pkg} (dev)`), ].join(', ') + const promptParts: string[] = [] + if (missing.length > 0) promptParts.push(`Install ${missingList}`) + if (offerAlign) + promptParts.push( + `align ${skewed.map(({ pkg }) => pkg).join(', ')} to this release`, + ) - // Non-interactive (CI, agents, pipes): no TTY to answer, so install by - // default and continue rather than abort. `stash init` is a setup command; - // installing its own dependencies is the expected non-interactive default. + // Non-interactive (CI, agents, pipes): no TTY to answer, so install the + // MISSING packages by default and continue rather than abort. `stash init` + // is a setup command; installing its own dependencies is the expected + // non-interactive default. (Alignment of existing installs is excluded + // above — that mutation needs explicit consent.) if (!isInteractive()) { p.log.info(`Installing ${missingList} (non-interactive).`) + } else if (offerAlign) { + // The confirm below covers alignment too; include it in the commands. + prodPackages.push(...alignSplit.prod) + devPackages.push(...alignSplit.dev) } + const commands = combinedInstallCommands(pm, prodPackages, devPackages) + const install = isInteractive() ? await p.confirm({ - message: `Install ${missingList}? (${commands.join(' && ')})`, + message: `${promptParts.join('; ')}? (${commands.join(' && ')})`, initialValue: true, }) : true @@ -202,16 +270,13 @@ export const installDepsStep: InitStep = { if (stackInstalled && cliInstalled && integrationInstalled) { p.log.success('Stack dependencies installed.') - // Fresh installs above are pinned, but packages that were ALREADY - // present were not touched — check the whole set for skew. - warnOnVersionSkew(allPackages) } else { const stillMissing = [ - ...(stackInstalled ? [] : [`${STACK_PACKAGE} (prod)`]), + ...(stackInstalled ? [] : [`${pinnedSpec(STACK_PACKAGE)} (prod)`]), ...(integrationPkg && !integrationInstalled - ? [`${integrationPkg} (prod)`] + ? [`${pinnedSpec(integrationPkg)} (prod)`] : []), - ...(cliInstalled ? [] : [`${CLI_PACKAGE} (dev)`]), + ...(cliInstalled ? [] : [`${pinnedSpec(CLI_PACKAGE)} (dev)`]), ] p.log.warn(`Still missing: ${stillMissing.join(', ')}.`) p.note( diff --git a/packages/cli/src/commands/init/steps/install-eql.ts b/packages/cli/src/commands/init/steps/install-eql.ts index e17f90e35..5ebc682e9 100644 --- a/packages/cli/src/commands/init/steps/install-eql.ts +++ b/packages/cli/src/commands/init/steps/install-eql.ts @@ -1,5 +1,6 @@ import * as p from '@clack/prompts' import { isInteractive } from '../../../config/tty.js' +import { pinnedSpec } from '../../../runtime-versions.js' import { installCommand } from '../../db/install.js' import type { InitProvider, InitState, InitStep } from '../types.js' import { CancelledError } from '../types.js' @@ -79,8 +80,11 @@ export const installEqlStep: InitStep = { p.log.error( '`stash` is not installed in this project. The previous step (install-deps) was skipped or failed. Re-run `stash init` and accept the dependency install when prompted, or install it manually:', ) + // Pinned to this release's version (#661) — a bare `stash` here resolves + // the `latest` dist-tag, which can lag during pre-release windows. + const spec = pinnedSpec('stash') p.note( - ' npm install --save-dev stash\n pnpm add -D stash\n yarn add -D stash\n bun add -D stash', + ` npm install --save-dev ${spec}\n pnpm add -D ${spec}\n yarn add -D ${spec}\n bun add -D ${spec}`, 'Then re-run init', ) return { ...state, eqlInstalled: false } diff --git a/packages/cli/src/commands/wizard/index.ts b/packages/cli/src/commands/wizard/index.ts index 88d1d05e7..1f8d85cc0 100644 --- a/packages/cli/src/commands/wizard/index.ts +++ b/packages/cli/src/commands/wizard/index.ts @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process' import * as p from '@clack/prompts' +import { pinnedSpec } from '../../runtime-versions.js' import { detectPackageManager, isPackageInstalled, @@ -40,7 +41,11 @@ export async function runWizardSpawn( passthroughArgs: string[], ): Promise { const pm = detectPackageManager() - const runner = runnerCommand(pm, WIZARD_PACKAGE) + // Pin the one-shot run to this release's wizard version (#661): a bare + // `npx @cipherstash/wizard` resolves `latest`, which can lag or point at a + // placeholder during pre-release windows — executing a DIFFERENT release + // than the CLI that spawned it. + const runner = runnerCommand(pm, pinnedSpec(WIZARD_PACKAGE)) const cached = isPackageInstalled(WIZARD_PACKAGE) if (cached) { diff --git a/packages/cli/src/config/__tests__/missing-package.test.ts b/packages/cli/src/config/__tests__/missing-package.test.ts index 10b5eeb97..2bce67410 100644 --- a/packages/cli/src/config/__tests__/missing-package.test.ts +++ b/packages/cli/src/config/__tests__/missing-package.test.ts @@ -1,5 +1,22 @@ -import { describe, expect, it } from 'vitest' -import { missingCipherStashPackage } from '../missing-package.js' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + missingCipherStashPackage, + reportMissingCipherStashPackage, +} from '../missing-package.js' + +// Pin map fixture so the guidance path is testable in source mode (where the +// build-time embed is absent). Versions are deliberately unreal. +vi.mock('../../runtime-versions.js', () => ({ + RUNTIME_PACKAGE_VERSIONS: { stash: '9.9.9-test.1' }, + expectedVersion: (pkg: string) => + ({ stash: '9.9.9-test.1', '@cipherstash/stack': '9.9.9-test.1' })[pkg], + pinnedSpec: (pkg: string) => { + const v = { stash: '9.9.9-test.1', '@cipherstash/stack': '9.9.9-test.1' }[ + pkg + ] + return v ? `${pkg}@${v}` : pkg + }, +})) function moduleErr(message: string, code = 'MODULE_NOT_FOUND'): Error { return Object.assign(new Error(message), { code }) @@ -53,3 +70,25 @@ describe('missingCipherStashPackage', () => { expect(missingCipherStashPackage('boom')).toBeUndefined() }) }) + +describe('reportMissingCipherStashPackage', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('prints PINNED install commands and exits 1 (#661)', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('exit') + }) as never) + + expect(() => reportMissingCipherStashPackage('stash')).toThrow('exit') + + expect(exitSpy).toHaveBeenCalledWith(1) + const message = errSpy.mock.calls.map((c) => c.join(' ')).join('\n') + // The guidance must name exact versions — a bare package name resolves + // dist-tags, which is the #661 failure mode this guidance exists to avoid. + expect(message).toContain('@cipherstash/stack@9.9.9-test.1') + expect(message).toContain('stash@9.9.9-test.1') + }) +}) diff --git a/packages/cli/src/native.ts b/packages/cli/src/native.ts index 969b35478..e79e33e38 100644 --- a/packages/cli/src/native.ts +++ b/packages/cli/src/native.ts @@ -20,6 +20,7 @@ import { type ModuleError, moduleNotFoundSpecifier, } from './module-error.js' +import { pinnedSpec } from './runtime-versions.js' // Matches the platform-suffixed optional package, e.g. // `@cipherstash/protect-ffi-darwin-arm64` or `@cipherstash/auth-linux-x64-gnu`. @@ -84,7 +85,11 @@ export function reportNativeBinaryMissing(err: unknown): void { const pm = detectPackageManager() // Runner-aware so we don't hardcode `npx` (see scripts/lint-no-hardcoded-runners.mjs): // npm → `npx`, bun → `bunx`, pnpm/yarn → `… dlx`. - const rerun = `${runnerCommand(pm, 'stash@latest')} ` + // Pinned to THIS release (#661): `stash@latest` could resolve a different + // (older) CLI during a pre-release window, silently switching releases + // mid-diagnosis. Re-running the same version with a cleared cache is the + // recovery we actually want. + const rerun = `${runnerCommand(pm, pinnedSpec('stash'))} ` // The one-shot runner cache is npm-specific (`_npx`); for other package // managers a clean re-run is the equivalent first step. Shell syntax differs // on Windows (PowerShell), which is a supported target. diff --git a/packages/cli/src/release-train.ts b/packages/cli/src/release-train.ts new file mode 100644 index 000000000..3ad6ffd60 --- /dev/null +++ b/packages/cli/src/release-train.ts @@ -0,0 +1,27 @@ +/** + * The single source of truth for which packages ride this CLI's release train + * — i.e. which packages `stash init` (and other guidance) must pin to the + * versions this CLI release was built alongside (#661). + * + * Consumed from BOTH sides of the build boundary, which is the point: + * + * - `tsup.config.ts` iterates this map at build time to read each sibling + * workspace manifest and embed the versions (`__STASH_RUNTIME_VERSIONS__`). + * - Runtime code (`install-deps.ts`) declares its integration-adapter + * packages against it, and a unit test asserts every adapter package is a + * key here — so adding a new adapter without adding it to the train FAILS + * the build's tests instead of silently installing unpinned and being + * exempt from the skew warning. + * + * Values are manifest paths relative to `packages/cli/`. + */ +export const RELEASE_TRAIN_MANIFESTS = { + stash: './package.json', + '@cipherstash/stack': '../stack/package.json', + '@cipherstash/stack-drizzle': '../stack-drizzle/package.json', + '@cipherstash/stack-supabase': '../stack-supabase/package.json', + '@cipherstash/prisma-next': '../prisma-next/package.json', + '@cipherstash/wizard': '../wizard/package.json', +} as const + +export type ReleaseTrainPackage = keyof typeof RELEASE_TRAIN_MANIFESTS diff --git a/packages/cli/src/runtime-versions.ts b/packages/cli/src/runtime-versions.ts index 62b7ee64b..d79173ea3 100644 --- a/packages/cli/src/runtime-versions.ts +++ b/packages/cli/src/runtime-versions.ts @@ -10,38 +10,63 @@ * delivered a *different release than the CLI running it* — broken `/v3` * imports, and eval/agent runs that thought they were testing an rc while * exercising the old stable. Pinning to the versions from this CLI's own - * release train makes `init` deterministic regardless of dist-tag state. + * release train (see `src/release-train.ts`) makes `init` deterministic + * regardless of dist-tag state. * * The embed mirrors `__STASH_POSTHOG_KEY__` (see `src/telemetry/index.ts`): - * `tsup.config.ts` reads each sibling workspace package's `package.json` at + * `tsup.config.ts` reads each release-train package's `package.json` at * build time and defines `__STASH_RUNTIME_VERSIONS__` as a JSON string. Every * tsup build gets it (it needs no env var, unlike the PostHog key); only * source-mode runs (unit tests, direct `tsx` execution) leave the identifier * undefined, in which case {@link pinnedSpec} degrades to the bare package * name — today's behaviour, and irrelevant to shipped artifacts. + * + * Failure policy: an ABSENT embed is legitimate (source mode) and yields an + * empty map. A PRESENT-but-malformed embed is a build defect and THROWS — + * degrading it silently to `{}` would quietly reintroduce unpinned installs, + * the exact regression this module exists to prevent, with no signal anywhere. */ declare const __STASH_RUNTIME_VERSIONS__: string | undefined -/** Parse and validate the build-time embed; empty map when absent/malformed. */ -function embeddedVersions(): Record { - if (typeof __STASH_RUNTIME_VERSIONS__ !== 'string') return {} +/** + * Parse the build-time embed. `undefined` (source mode — no embed) → empty + * map. A present but malformed value (unparseable, wrong shape, non-string + * versions) is a build defect: throw loudly instead of degrading to unpinned + * installs. Exported for direct unit testing. + */ +export function parseEmbeddedVersions( + raw: string | undefined, +): Record { + if (raw === undefined) return {} + const fail = (why: string): never => { + throw new Error( + `stash build defect: __STASH_RUNTIME_VERSIONS__ is ${why}. ` + + 'This binary cannot know which package versions to pin and will not ' + + 'fall back to unpinned installs (#661) — rebuild the CLI.', + ) + } + let parsed: unknown try { - const parsed: unknown = JSON.parse(__STASH_RUNTIME_VERSIONS__) - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) - return {} - const map: Record = {} - for (const [pkg, version] of Object.entries(parsed)) { - if (typeof version === 'string' && version.length > 0) map[pkg] = version - } - return map + parsed = JSON.parse(raw) } catch { - return {} + return fail('not valid JSON') + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) + return fail('not a plain object') + for (const [pkg, version] of Object.entries(parsed)) { + if (typeof version !== 'string' || version.length === 0) + return fail(`missing a usable version for "${pkg}"`) } + return parsed as Record } /** Package name → exact version from this CLI release's build. */ export const RUNTIME_PACKAGE_VERSIONS: Readonly> = - embeddedVersions() + parseEmbeddedVersions( + typeof __STASH_RUNTIME_VERSIONS__ === 'string' + ? __STASH_RUNTIME_VERSIONS__ + : undefined, + ) /** * The version of `pkg` this CLI release expects, or `undefined` when the diff --git a/packages/cli/tests/e2e/runtime-versions-embed.e2e.test.ts b/packages/cli/tests/e2e/runtime-versions-embed.e2e.test.ts new file mode 100644 index 000000000..35776f347 --- /dev/null +++ b/packages/cli/tests/e2e/runtime-versions-embed.e2e.test.ts @@ -0,0 +1,57 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { RELEASE_TRAIN_MANIFESTS } from '../../src/release-train.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const CLI_ROOT = path.resolve(__dirname, '../..') + +/** + * Guards the build-time version embed end to end against the BUILT artifact + * (#661): if the tsup define were dropped, renamed, or fed the wrong shape, + * the shipped CLI would degrade to bare (dist-tag-resolved) installs. The + * runtime code paths are covered by unit tests; this asserts the artifact + * they run in actually carries the embed, with the exact versions of the + * workspace manifests — the same source tsup reads — so it never needs + * updating per release. + * + * (The behavioural route — driving the built CLI onto the missing-package + * guidance in a bare temp project — is unreachable under this harness: jiti + * resolves `stash`/`@cipherstash/stack` from the monorepo's own node_modules + * regardless of cwd.) + */ +describe('runtime-versions build embed', () => { + it('the built bundles embed the release-train versions', () => { + const expected: Record = {} + for (const [pkg, rel] of Object.entries(RELEASE_TRAIN_MANIFESTS)) { + const manifest = JSON.parse( + fs.readFileSync(path.resolve(CLI_ROOT, rel), 'utf8'), + ) as { version: string } + expected[pkg] = manifest.version + } + + // Both bundles inline runtime-versions (dist/index for the library entry, + // the bin chunk for the CLI). Losing the define in either would silently + // unpin that bundle, so check each. + const binDir = path.join(CLI_ROOT, 'dist', 'bin') + const bundles = [ + path.join(CLI_ROOT, 'dist', 'index.js'), + ...fs + .readdirSync(binDir) + .filter((f) => f.endsWith('.js')) + .map((f) => path.join(binDir, f)), + ] + + for (const [pkg, version] of Object.entries(expected)) { + // The embed is a JSON string literal in the bundle; the exact escaping + // varies by bundler, so assert on the stable `"pkg":"version"` pair + // (tolerating escaped quotes). + const pair = new RegExp( + `\\\\?"${pkg.replace(/[/@]/g, (c) => `\\${c}`)}\\\\?":\\\\?"${version.replace(/\./g, '\\.')}\\\\?"`, + ) + const found = bundles.some((b) => pair.test(fs.readFileSync(b, 'utf8'))) + expect(found, `${pkg}@${version} embedded in a shipped bundle`).toBe(true) + } + }) +}) diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 00ba1a2ac..a65090867 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -1,5 +1,6 @@ import { cpSync, existsSync, readFileSync } from 'node:fs' import { defineConfig } from 'tsup' +import { RELEASE_TRAIN_MANIFESTS } from './src/release-train.js' /** * Build-time value for the embedded PostHog project key (see @@ -18,12 +19,16 @@ const posthogKeyDefine = { * (see `src/runtime-versions.ts` and #661): `stash init` pins the packages it * installs to the versions this CLI was built alongside, instead of trusting * npm dist-tags (which lag, or point at placeholders, during pre-release - * windows). Versions are read straight from the sibling workspace manifests so - * the embed can never disagree with what Changesets is about to publish; a - * missing/broken manifest throws at build time — a silently absent embed would - * quietly reintroduce unpinned installs. Unlike the PostHog key this needs no - * env var, so EVERY build embeds it. The double `JSON.stringify` makes the - * define value a string literal that `runtime-versions.ts` JSON-parses. + * windows). The package list is the shared `RELEASE_TRAIN_MANIFESTS` + * (`src/release-train.ts`) — the same constant the runtime cross-checks its + * adapter list against — and versions are read straight from the sibling + * workspace manifests so the embed can never disagree with what Changesets is + * about to publish. A missing/broken manifest throws at build time — a + * silently absent embed would quietly reintroduce unpinned installs. Unlike + * the PostHog key this needs no env var, so EVERY build embeds it. The double + * `JSON.stringify` makes the define value a string literal that + * `runtime-versions.ts` parses and validates (throwing on a malformed embed + * rather than degrading to unpinned). */ function workspaceVersion(relPkgJson: string): string { const manifest: unknown = JSON.parse( @@ -36,22 +41,22 @@ function workspaceVersion(relPkgJson: string): string { } const runtimeVersionsDefine = { __STASH_RUNTIME_VERSIONS__: JSON.stringify( - JSON.stringify({ - stash: workspaceVersion('./package.json'), - '@cipherstash/stack': workspaceVersion('../stack/package.json'), - '@cipherstash/stack-drizzle': workspaceVersion( - '../stack-drizzle/package.json', + JSON.stringify( + Object.fromEntries( + Object.entries(RELEASE_TRAIN_MANIFESTS).map(([pkg, manifest]) => [ + pkg, + workspaceVersion(manifest), + ]), ), - '@cipherstash/stack-supabase': workspaceVersion( - '../stack-supabase/package.json', - ), - '@cipherstash/prisma-next': workspaceVersion( - '../prisma-next/package.json', - ), - }), + ), ), } +// One shared define object spread into BOTH build entries — a define added to +// one entry but not the other would leave that bundle silently degraded (both +// consumers typeof-guard their identifier). +const buildDefines = { ...posthogKeyDefine, ...runtimeVersionsDefine } + export default defineConfig([ { entry: ['src/index.ts'], @@ -68,11 +73,7 @@ export default defineConfig([ ...options.logOverride, 'empty-import-meta': 'silent', } - options.define = { - ...options.define, - ...posthogKeyDefine, - ...runtimeVersionsDefine, - } + options.define = { ...options.define, ...buildDefines } }, onSuccess: async () => { // Copy bundled SQL files into dist so they ship with the package @@ -109,11 +110,7 @@ var require = __createRequire(import.meta.url);`, skipNodeModulesBundle: true, esbuildOptions(options) { - options.define = { - ...options.define, - ...posthogKeyDefine, - ...runtimeVersionsDefine, - } + options.define = { ...options.define, ...buildDefines } }, }, ]) diff --git a/skills/stash-drizzle/SKILL.md b/skills/stash-drizzle/SKILL.md index 9269526f4..09335978d 100644 --- a/skills/stash-drizzle/SKILL.md +++ b/skills/stash-drizzle/SKILL.md @@ -24,6 +24,13 @@ In EQL v3 every encrypted column is a **concrete Postgres domain** (`public.eql_ npm install @cipherstash/stack @cipherstash/stack-drizzle drizzle-orm ``` +> **Version note:** `npx stash init` is the preferred install path — it pins +> every `@cipherstash/*` package to the versions matching your CLI release. +> If you install manually as above, verify what actually resolved +> (`node -p "require('@cipherstash/stack/package.json').version"`): bare +> dist-tag installs can lag behind a release, and `stash init` will warn on +> the version skew. + The Drizzle integration ships as its own first-party package, `@cipherstash/stack-drizzle`, which depends on `@cipherstash/stack`. Install both. The v3 surface documented here lives on the `@cipherstash/stack-drizzle/v3` subpath. diff --git a/skills/stash-dynamodb/SKILL.md b/skills/stash-dynamodb/SKILL.md index b5c9a305b..3c8c26b19 100644 --- a/skills/stash-dynamodb/SKILL.md +++ b/skills/stash-dynamodb/SKILL.md @@ -22,6 +22,13 @@ Guide for integrating CipherStash field-level encryption with Amazon DynamoDB us npm install @cipherstash/stack @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb ``` +> **Version note:** `npx stash init` is the preferred install path — it pins +> every `@cipherstash/*` package to the versions matching your CLI release. +> If you install manually as above, verify what actually resolved +> (`node -p "require('@cipherstash/stack/package.json').version"`): bare +> dist-tag installs can lag behind a release, and `stash init` will warn on +> the version skew. + ## How It Works CipherStash encrypts each attribute into two DynamoDB attributes: diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 344c6f97d..73a7a88ff 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -50,6 +50,13 @@ const decrypted = await client.decrypt(encrypted.data) npm install @cipherstash/stack ``` +> **Version note:** `npx stash init` is the preferred install path — it pins +> every `@cipherstash/*` package to the versions matching your CLI release. +> If you install manually as above, verify what actually resolved +> (`node -p "require('@cipherstash/stack/package.json').version"`): bare +> dist-tag installs can lag behind a release, and `stash init` will warn on +> the version skew. + > [!IMPORTANT] > **Exclude `@cipherstash/stack` from bundling — required for any project with a bundler (Next.js, webpack, esbuild, vite SSR, etc.).** The package wraps a native FFI module (`@cipherstash/protect-ffi`) that cannot be bundled. Importing the encryption client from server code without this exclusion will fail at runtime with errors about missing native modules. Configure as soon as you install the package; do not skip this step. diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index 1ed8a4409..24292d260 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -31,6 +31,13 @@ deployments — see "Legacy: EQL v2" at the end. New projects should use npm install @cipherstash/stack @cipherstash/stack-supabase @supabase/supabase-js ``` +> **Version note:** `npx stash init` is the preferred install path — it pins +> every `@cipherstash/*` package to the versions matching your CLI release. +> If you install manually as above, verify what actually resolved +> (`node -p "require('@cipherstash/stack/package.json').version"`): bare +> dist-tag installs can lag behind a release, and `stash init` will warn on +> the version skew. + The Supabase integration ships as its own first-party package, `@cipherstash/stack-supabase`, which depends on `@cipherstash/stack`. Install both.