diff --git a/.changeset/config.json b/.changeset/config.json index 9d6ca60c..2bacd138 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,7 +2,16 @@ "$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, - "fixed": [], + "fixed": [ + [ + "stash", + "@cipherstash/stack", + "@cipherstash/stack-drizzle", + "@cipherstash/stack-supabase", + "@cipherstash/prisma-next", + "@cipherstash/wizard" + ] + ], "linked": [], "access": "restricted", "baseBranch": "main", diff --git a/.changeset/prisma-next-joins-release-train.md b/.changeset/prisma-next-joins-release-train.md new file mode 100644 index 00000000..fe9c35e4 --- /dev/null +++ b/.changeset/prisma-next-joins-release-train.md @@ -0,0 +1,9 @@ +--- +'@cipherstash/prisma-next': patch +--- + +`@cipherstash/prisma-next` now versions in lockstep with the Stack release +train (`stash`, `@cipherstash/stack`, and the other adapters) via a Changesets +`fixed` group — `stash init` installs it pinned by exact version, so the two +must always release together. This moves the package from its previous `0.4.x` +line onto the shared train version; no API changes. diff --git a/.changeset/release-train-coupling.md b/.changeset/release-train-coupling.md new file mode 100644 index 00000000..dffd0579 --- /dev/null +++ b/.changeset/release-train-coupling.md @@ -0,0 +1,25 @@ +--- +'stash': patch +--- + +Two guards for the release-train version embed (#661 follow-up): + +**Direction-aware version skew.** `stash init` now distinguishes an installed +package that is *behind* this CLI release (offered alignment / the pinned +install command, as before) from one that is *newer* than the release expects. +A newer install no longer produces a downgrade command — init prints the exact +`stash` update command instead (release-train lockstep guarantees that version +exists), and when missing packages are about to be installed alongside newer +ones it says the pairing may not match and to update `stash` first. Unreadable +or malformed manifest versions always count as behind (a broken install should +be offered the reinstall fix, never "looks newer, leave it"). + +**Version lockstep.** The release-train packages (`stash`, +`@cipherstash/stack`, `@cipherstash/stack-drizzle`, +`@cipherstash/stack-supabase`, `@cipherstash/prisma-next`, +`@cipherstash/wizard`) are now a Changesets `fixed` group: a release of any of +them republishes all of them at the same version, so the CLI's embedded +version map can never go stale against the packages it pins (previously a +runtime-package-only release would have left the published CLI embedding — +and recommending — outdated versions). A test now asserts the fixed group +stays exactly equal to the release train. diff --git a/.changeset/wizard-joins-release-train.md b/.changeset/wizard-joins-release-train.md new file mode 100644 index 00000000..ccc445c7 --- /dev/null +++ b/.changeset/wizard-joins-release-train.md @@ -0,0 +1,9 @@ +--- +'@cipherstash/wizard': patch +--- + +`@cipherstash/wizard` now versions in lockstep with the Stack release train +(`stash`, `@cipherstash/stack`, and the adapters) via a Changesets `fixed` +group — the `stash` CLI executes the wizard by exact version, so the two must +always release together. This moves the package from its previous `0.5.x` +line onto the shared train version; no API changes. diff --git a/packages/cli/src/__tests__/release-train.test.ts b/packages/cli/src/__tests__/release-train.test.ts index 36dafd40..7776d1e4 100644 --- a/packages/cli/src/__tests__/release-train.test.ts +++ b/packages/cli/src/__tests__/release-train.test.ts @@ -6,6 +6,7 @@ import { INTEGRATION_ADAPTER_PACKAGES } from '../commands/init/steps/install-dep import { RELEASE_TRAIN_MANIFESTS } from '../release-train.js' const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const REPO_ROOT = resolve(CLI_ROOT, '../..') // The growth guard for #661: `RELEASE_TRAIN_MANIFESTS` is the single source // the build embeds versions from, and `INTEGRATION_ADAPTER_PACKAGES` is what @@ -46,4 +47,20 @@ describe('release train coverage', () => { expect((m.version as string).length).toBeGreaterThan(0) } }) + + it('the changesets fixed group is exactly the release train (staleness guard)', () => { + // The staleness half of #661/#669 is release-process config: every train + // package must version in lockstep with `stash`, or a release of the odd + // one out ships while the published CLI still embeds — and pins — its old + // version. Guard the config like the code: group membership must equal + // the train, exactly. + const config = JSON.parse( + readFileSync(resolve(REPO_ROOT, '.changeset/config.json'), 'utf8'), + ) as { fixed?: string[][] } + const group = config.fixed?.find((g) => g.includes('stash')) + expect(group, 'a changesets fixed group containing stash').toBeDefined() + expect(new Set(group)).toEqual( + new Set(Object.keys(RELEASE_TRAIN_MANIFESTS)), + ) + }) }) diff --git a/packages/cli/src/__tests__/runtime-versions.test.ts b/packages/cli/src/__tests__/runtime-versions.test.ts index a3f3b668..20f35dea 100644 --- a/packages/cli/src/__tests__/runtime-versions.test.ts +++ b/packages/cli/src/__tests__/runtime-versions.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + compareVersions, expectedVersion, parseEmbeddedVersions, pinnedSpec, @@ -94,3 +95,51 @@ describe('runtime-versions (explicit version map)', () => { expect(expectedVersion('nope', versions)).toBeUndefined() }) }) + +// Precedence per semver §11, over the shapes this repo actually publishes. +describe('compareVersions', () => { + it('orders numeric cores', () => { + expect(compareVersions('0.19.0', '1.0.0-rc.1')).toBe(-1) + expect(compareVersions('1.1.0', '1.0.9')).toBe(1) + expect(compareVersions('1.0.0', '1.0.0')).toBe(0) + }) + + it('a release outranks its own prereleases', () => { + expect(compareVersions('1.0.0', '1.0.0-rc.9')).toBe(1) + expect(compareVersions('1.0.0-rc.9', '1.0.0')).toBe(-1) + }) + + it('orders prerelease identifiers numerically, not lexically', () => { + expect(compareVersions('1.0.0-rc.2', '1.0.0-rc.1')).toBe(1) + // Lexical comparison would get this one wrong ('10' < '2'). + expect(compareVersions('1.0.0-rc.10', '1.0.0-rc.2')).toBe(1) + expect(compareVersions('1.0.0-rc.1', '1.0.0-rc.1')).toBe(0) + }) + + it('treats any malformed version as not comparable (never "ahead")', () => { + // A corrupt manifest version must not partially parse into 1 ("installed + // is newer") — that would suppress the align guidance. The strict shape + // gate rejects every malformed class, not just NaN-able cores: + expect(compareVersions('v1.0.0', '1.0.0')).toBe(0) + expect(compareVersions('1.0.x', '1.0.0')).toBe(0) + expect(compareVersions('garbage', '1.0.0')).toBe(0) + // truncated core — '1.0' padded to 1.0.0 would outrank 1.0.0-rc.2 + expect(compareVersions('1.0', '1.0.0-rc.2')).toBe(0) + // extra core segment + expect(compareVersions('1.2.3.4', '1.2.3')).toBe(0) + // empty prerelease — '1.0.0-' as a release would outrank any rc + expect(compareVersions('1.0.0-', '1.0.0-rc.1')).toBe(0) + // parseInt trailing garbage — '9.9.10rc' would parse as core 9.9.10 + expect(compareVersions('9.9.10rc', '9.9.9')).toBe(0) + expect(compareVersions('1.0.0beta', '1.0.0-rc.1')).toBe(0) + // build metadata — '2+sha' as an identifier would mis-order vs 'rc.10' + expect(compareVersions('1.0.0-rc.2+sha.abc', '1.0.0-rc.10')).toBe(0) + // empty prerelease identifier + expect(compareVersions('1.0.0-rc..1', '1.0.0-rc.1')).toBe(0) + }) + + it('numeric identifiers sort below alphanumeric; shorter below longer', () => { + expect(compareVersions('1.0.0-1', '1.0.0-alpha')).toBe(-1) + expect(compareVersions('1.0.0-rc', '1.0.0-rc.1')).toBe(-1) + }) +}) 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 3564fe7c..160c6fa7 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 @@ -13,6 +13,9 @@ vi.mock('../../utils.js', () => ({ ...(dev.length ? [`npm install --save-dev ${dev.join(' ')}`] : []), ], ), + devInstallCommand: vi.fn( + (_pm: string, pkg: string) => `npm install -D ${pkg}`, + ), detectPackageManager: vi.fn(() => 'npm'), })) // Pin map: pretend this CLI release was built alongside these versions, so @@ -26,7 +29,10 @@ const FIXTURE_VERSIONS: Record = vi.hoisted(() => ({ '@cipherstash/stack': '9.9.9-test.1', '@cipherstash/stack-supabase': '9.9.9-test.1', })) -vi.mock('../../../../runtime-versions.js', () => ({ +vi.mock('../../../../runtime-versions.js', async (importOriginal) => ({ + // Keep the real pure helpers (compareVersions, parseEmbeddedVersions); + // override only the release map and the map-reading functions. + ...(await importOriginal()), RUNTIME_PACKAGE_VERSIONS: FIXTURE_VERSIONS, expectedVersion: ( pkg: string, @@ -216,6 +222,58 @@ describe('installDepsStep', () => { expect(result.cliInstalled).toBe(true) }) + it('a NEWER install gets an update-stash warning, never a downgrade command', async () => { + vi.mocked(isInteractive).mockReturnValue(false) + present('@cipherstash/stack', 'stash') + resolvedVersions({ + '@cipherstash/stack': '9.9.10', + stash: FIXTURE_VERSIONS.stash, + }) + + const result = await installDepsStep.run(baseState, provider) + + expect(p.log.warn).toHaveBeenCalledWith( + expect.stringContaining( + '@cipherstash/stack: installed 9.9.10 is newer than this release of stash expects (9.9.9-test.1)', + ), + ) + // Lockstep means stash@ must exist — the warning prints the + // exact update command instead of an uncommanded "matching release". + expect(p.log.warn).toHaveBeenCalledWith( + expect.stringContaining('npm install -D stash@9.9.10'), + ) + // No align/downgrade guidance, no mutation, clean success. + expect(p.note).not.toHaveBeenCalled() + expect(execSyncMock).not.toHaveBeenCalled() + expect(result.stackInstalled).toBe(true) + }) + + it('mixed ahead + missing: warns that fresh installs pin to THIS release and may mismatch', async () => { + vi.mocked(isInteractive).mockReturnValue(false) + // stack is installed and NEWER; the supabase adapter is missing — init + // will install the adapter at this CLI's older embed, so it must say the + // pairing may not match rather than silently manufacturing a cross-train + // mismatch under a "likely fine" banner. + present('@cipherstash/stack', 'stash') + resolvedVersions({ + '@cipherstash/stack': '9.9.10', + stash: FIXTURE_VERSIONS.stash, + }) + + await installDepsStep.run(baseState, supabaseProvider) + + expect(p.log.warn).toHaveBeenCalledWith( + expect.stringContaining( + "@cipherstash/stack-supabase will be installed at THIS release's versions", + ), + ) + expect(p.log.warn).toHaveBeenCalledWith( + expect.stringContaining('npm install -D stash@9.9.10'), + ) + // The missing adapter still installs (non-interactive default). + expect(execSyncMock).toHaveBeenCalled() + }) + it('reports an unreadable manifest as skew, not as a matching install', async () => { vi.mocked(isInteractive).mockReturnValue(false) present('@cipherstash/stack', 'stash') @@ -257,6 +315,24 @@ describe('versionSkew', () => { pkg: '@cipherstash/stack', installed: '0.19.0', expected: '9.9.9-test.1', + direction: 'behind', + }, + ]) + }) + + it('classifies a newer-than-expected install as ahead', () => { + present('@cipherstash/stack') + resolvedVersions({ '@cipherstash/stack': '9.9.10' }) + expect( + versionSkew(['@cipherstash/stack'], { + '@cipherstash/stack': '9.9.9-test.1', + }), + ).toEqual([ + { + pkg: '@cipherstash/stack', + installed: '9.9.10', + expected: '9.9.9-test.1', + direction: 'ahead', }, ]) }) @@ -274,6 +350,23 @@ describe('versionSkew', () => { expect(versionSkew(['@no-map/package'], {})).toEqual([]) }) + it('classifies a garbage installed version as behind (align guidance), not ahead', () => { + present('@cipherstash/stack') + resolvedVersions({ '@cipherstash/stack': 'not-a-version' }) + expect( + versionSkew(['@cipherstash/stack'], { + '@cipherstash/stack': '9.9.9-test.1', + }), + ).toEqual([ + { + pkg: '@cipherstash/stack', + installed: 'not-a-version', + expected: '9.9.9-test.1', + direction: 'behind', + }, + ]) + }) + it('flags an installed package with an unreadable manifest', () => { present('@cipherstash/stack') resolvedVersions({ '@cipherstash/stack': undefined }) @@ -286,6 +379,8 @@ describe('versionSkew', () => { pkg: '@cipherstash/stack', installed: 'unknown (unreadable package.json)', expected: '9.9.9-test.1', + // Unreadable = broken install → offer the (re)install fix. + direction: 'behind', }, ]) }) diff --git a/packages/cli/src/commands/init/steps/install-deps.ts b/packages/cli/src/commands/init/steps/install-deps.ts index f4f4ac7e..0853d73b 100644 --- a/packages/cli/src/commands/init/steps/install-deps.ts +++ b/packages/cli/src/commands/init/steps/install-deps.ts @@ -2,6 +2,7 @@ import { execSync } from 'node:child_process' import * as p from '@clack/prompts' import { isInteractive } from '../../../config/tty.js' import { + compareVersions, expectedVersion, pinnedSpec, RUNTIME_PACKAGE_VERSIONS, @@ -11,6 +12,7 @@ import { CancelledError } from '../types.js' import { combinedInstallCommands, detectPackageManager, + devInstallCommand, installedVersion, isPackageInstalled, } from '../utils.js' @@ -49,6 +51,10 @@ export type VersionSkewEntry = { pkg: string installed: string expected: string + /** `behind`: older than this release (or unreadable) — offer alignment. + * `ahead`: NEWER than this release expects — the install is likely fine + * and the fix is updating stash, never downgrading the package. */ + direction: 'behind' | 'ahead' } /** @@ -71,7 +77,15 @@ export function versionSkew( if (!expected) continue if (!isPackageInstalled(pkg)) continue const installed = installedVersion(pkg) ?? UNREADABLE_VERSION - if (installed !== expected) skewed.push({ pkg, installed, expected }) + if (installed === expected) continue + // Unreadable manifests are treated as `behind`: a broken install should + // be offered the (re)install fix, not a stash upgrade. + const direction = + installed !== UNREADABLE_VERSION && + compareVersions(installed, expected) > 0 + ? ('ahead' as const) + : ('behind' as const) + skewed.push({ pkg, installed, expected, direction }) } return skewed } @@ -87,6 +101,17 @@ function skewLines(skewed: readonly VersionSkewEntry[]): string { .join('\n ') } +/** Render the newer-than-expected lines: the fix is updating stash, never + * downgrading a runtime package past releases the project already uses. */ +function aheadLines(ahead: readonly VersionSkewEntry[]): string { + return ahead + .map( + ({ pkg, installed, expected }) => + `${pkg}: installed ${installed} is newer than 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[]): { @@ -151,7 +176,13 @@ export const installDepsStep: InitStep = { // failure, or early return can skip it (#661). Every path below inherits // this warning. const pm = detectPackageManager() - const skewed = versionSkew(allPackages) + const allSkew = versionSkew(allPackages) + // Direction matters (#666 review): only packages BEHIND this release get + // the align treatment. A package AHEAD of this release means the CLI is + // the stale side — advising a downgrade would walk the project back past + // releases it already depends on. + const skewed = allSkew.filter(({ direction }) => direction === 'behind') + const ahead = allSkew.filter(({ direction }) => direction === 'ahead') const alignSplit = splitProdDev(skewed.map(({ pkg }) => pkg)) const alignCommands = combinedInstallCommands( pm, @@ -169,6 +200,29 @@ export const installDepsStep: InitStep = { if (!cliPresent) missing.push(CLI_PACKAGE) const missingSplit = splitProdDev(missing) + if (ahead.length > 0) { + // Every release-train package versions in lockstep (the changesets + // `fixed` group), so a train package strictly ahead of this CLI's embed + // implies a stash release exists at that exact version — print the + // command instead of leaving the user to research "the matching + // release". Highest ahead version wins when several differ. + const target = ahead + .map(({ installed }) => installed) + .reduce((max, v) => (compareVersions(v, max) > 0 ? v : max)) + const updateCmd = devInstallCommand(pm, `stash@${target}`) + // Installing MISSING packages now would pin them to this CLI's older + // embed, pairing them with the newer installed packages — a combination + // no lockstep release ever shipped. Say so instead of silently + // manufacturing the mismatch. + const missingNote = + missing.length > 0 + ? `\nNote: ${missing.join(', ')} will be installed at THIS release's versions, which may not match the newer packages above — for a consistent set, update stash first and re-run init:\n ${updateCmd}` + : `\nUpdate with:\n ${updateCmd}\nthen re-run init.` + p.log.warn( + `Installed versions are newer than this release of stash:\n ${aheadLines(ahead)}\nYour installs are likely fine — update the stash CLI to the matching release instead of downgrading.${missingNote}`, + ) + } + // 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. diff --git a/packages/cli/src/runtime-versions.ts b/packages/cli/src/runtime-versions.ts index d79173ea..9e28b84c 100644 --- a/packages/cli/src/runtime-versions.ts +++ b/packages/cli/src/runtime-versions.ts @@ -92,3 +92,72 @@ export function pinnedSpec( const version = expectedVersion(pkg, versions) return version ? `${pkg}@${version}` : pkg } + +/** + * Compare two release-train version strings per semver precedence (§11): + * numeric core compared numerically, then prerelease identifiers dot-by-dot + * (numeric < alphanumeric; a release outranks any of its prereleases). + * Returns -1 / 0 / 1 for a < b / a == b / a > b. + * + * Deliberately NOT a full semver implementation — no ranges, no build + * metadata — just enough to order the versions this repo publishes + * (`x.y.z` and `x.y.z-rc.n`), so the skew warning can tell "behind" from + * "ahead" without adding a dependency to the CLI. + * + * A version that isn't strictly `digits.digits.digits` with an optional + * well-formed prerelease (`v1.0.0`, `1.0.x`, `1.0`, `1.0.0-`, `1.0.0beta`, + * `1.0.0-rc.2+sha` — build metadata deliberately rejected, and any other + * garbage from a corrupt manifest) is NOT COMPARABLE: return `0` rather + * than a partially-parsed order. Callers classify non-ahead as behind, so + * an unparseable installed version gets the safe treatment (align/reinstall + * guidance) instead of being silently promoted to "newer, leave it". + */ +/** Exactly three numeric core segments, optional dot-separated prerelease + * identifiers (each non-empty alphanumeric/hyphen). No build metadata: `+` + * fails the shape and the version is treated as not comparable — the safe + * direction — rather than mis-ordered by an identifier like `2+sha`. */ +const VERSION_SHAPE = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/ + +export function compareVersions(a: string, b: string): -1 | 0 | 1 { + if (!VERSION_SHAPE.test(a) || !VERSION_SHAPE.test(b)) return 0 + const parse = (v: string) => { + const [core, ...pre] = v.split('-') + return { + core: core.split('.').map((n) => Number.parseInt(n, 10)), + pre: pre.join('-'), // prerelease may itself contain '-' + } + } + const pa = parse(a) + const pb = parse(b) + for (let i = 0; i < 3; i++) { + // The shape gate guarantees exactly three numeric segments. + const da = pa.core[i] as number + const db = pb.core[i] as number + if (da !== db) return da < db ? -1 : 1 + } + // Same core: a release (no prerelease) outranks any prerelease of it. + if (!pa.pre && !pb.pre) return 0 + if (!pa.pre) return 1 + if (!pb.pre) return -1 + const ia = pa.pre.split('.') + const ib = pb.pre.split('.') + for (let i = 0; i < Math.max(ia.length, ib.length); i++) { + const xa = ia[i] + const xb = ib[i] + // Fewer identifiers sorts lower when all preceding ones are equal. + if (xa === undefined) return -1 + if (xb === undefined) return 1 + const na = /^\d+$/.test(xa) ? Number.parseInt(xa, 10) : undefined + const nb = /^\d+$/.test(xb) ? Number.parseInt(xb, 10) : undefined + if (na !== undefined && nb !== undefined) { + if (na !== nb) return na < nb ? -1 : 1 + } else if (na !== undefined) { + return -1 // numeric identifiers sort below alphanumeric ones + } else if (nb !== undefined) { + return 1 + } else if (xa !== xb) { + return xa < xb ? -1 : 1 + } + } + return 0 +} diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index 74f1a4ad..516d9c6e 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`). 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. +`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 — but the fix depends on direction, and init says which applies: an **older** install should be aligned to the release (init offers the exact command); a **newer** install must NOT be downgraded — update the `stash` CLI to the matching release instead (init prints that command too). **If you are an agent, do this first:**