From d80391416338276a155cfb0b253840a555d70bfa Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 16 Jul 2026 16:48:56 +1000 Subject: [PATCH 1/3] fix(cli): direction-aware version skew + release-train version lockstep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the remaining #666 review finding: post-GA, a stack-only release would not republish stash (stack is only a devDep + in-range optional peer), leaving the published CLI's embedded version map stale — fresh installs would pin outdated versions and the skew warning would print downgrade commands to users on legitimately newer versions. Two-part fix: 1. Changesets `fixed` group over the release train (stash, stack, both adapters, wizard): a release of any republishes all at the same version, so the embed can never disagree with what's live. prisma-next stays on its own line by design. NOTE: wizard joins the group from 0.5.0-rc.1 and will land on the group version (1.0.0-rc.2) at the next `changeset version`. 2. Direction-aware skew: versionSkew classifies behind vs ahead using a small prerelease-aware comparator (compareVersions — deliberately not a full semver impl; no new dependency per the supply-chain policy). Behind (and unreadable) installs get the align offer as before; an install NEWER than this release expects gets "update stash to the matching release" and never a downgrade command. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .changeset/config.json | 10 +++- .changeset/release-train-coupling.md | 23 ++++++++ .../src/__tests__/runtime-versions.test.ts | 27 ++++++++++ .../init/steps/__tests__/install-deps.test.ts | 46 +++++++++++++++- .../src/commands/init/steps/install-deps.ts | 39 +++++++++++++- packages/cli/src/runtime-versions.ts | 53 +++++++++++++++++++ 6 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 .changeset/release-train-coupling.md diff --git a/.changeset/config.json b/.changeset/config.json index 9d6ca60c..caed881d 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,7 +2,15 @@ "$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/wizard" + ] + ], "linked": [], "access": "restricted", "baseBranch": "main", diff --git a/.changeset/release-train-coupling.md b/.changeset/release-train-coupling.md new file mode 100644 index 00000000..3bc5b3dc --- /dev/null +++ b/.changeset/release-train-coupling.md @@ -0,0 +1,23 @@ +--- +'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 says the install +is likely fine and to update the stash CLI to the matching release instead. +Unreadable manifests still count as behind (a broken install should be offered +the reinstall fix). + +**Version lockstep.** The release-train packages (`stash`, +`@cipherstash/stack`, `@cipherstash/stack-drizzle`, +`@cipherstash/stack-supabase`, `@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 stack-only release would have left the +published CLI embedding — and recommending — outdated versions). +`@cipherstash/prisma-next` stays on its own version line by design; the +direction-aware messaging above covers it. diff --git a/packages/cli/src/__tests__/runtime-versions.test.ts b/packages/cli/src/__tests__/runtime-versions.test.ts index a3f3b668..005db867 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,29 @@ 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('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..20e96117 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 @@ -26,7 +26,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 +219,27 @@ 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)', + ), + ) + // No align/downgrade guidance, no mutation, clean success. + expect(p.note).not.toHaveBeenCalled() + expect(execSyncMock).not.toHaveBeenCalled() + expect(result.stackInstalled).toBe(true) + }) + it('reports an unreadable manifest as skew, not as a matching install', async () => { vi.mocked(isInteractive).mockReturnValue(false) present('@cipherstash/stack', 'stash') @@ -257,6 +281,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', }, ]) }) @@ -286,6 +328,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..7043e67d 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, @@ -49,6 +50,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 +76,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 +100,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 +175,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, @@ -161,6 +191,11 @@ export const installDepsStep: InitStep = { if (skewed.length > 0) { p.log.warn(`Version skew detected:\n ${skewLines(skewed)}`) } + if (ahead.length > 0) { + 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.`, + ) + } // What's missing outright (pinned, prod/dev split). const missing: string[] = [] diff --git a/packages/cli/src/runtime-versions.ts b/packages/cli/src/runtime-versions.ts index d79173ea..f64bb180 100644 --- a/packages/cli/src/runtime-versions.ts +++ b/packages/cli/src/runtime-versions.ts @@ -92,3 +92,56 @@ 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. + */ +export function compareVersions(a: string, b: string): -1 | 0 | 1 { + 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++) { + const da = pa.core[i] ?? 0 + const db = pb.core[i] ?? 0 + 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 +} From 83ec7ea631769ae468357dcda70d2ef3d8a6e19c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 16 Jul 2026 17:19:44 +1000 Subject: [PATCH 2/3] fix(cli): treat non-numeric version cores as not comparable, never "ahead" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review catch on #669: compareVersions parsed core segments with Number.parseInt and NaN !== NaN made any malformed version (v1.0.0, 1.0.x, garbage from a corrupt manifest) deterministically compare as GREATER — classifying it 'ahead' and suppressing the align/reinstall guidance exactly where it's needed. Guard the parse: either side with a NaN core returns 0 (not comparable), which callers classify as 'behind' — the safe direction. Tests at both layers (comparator + versionSkew classification). Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .../cli/src/__tests__/runtime-versions.test.ts | 8 ++++++++ .../init/steps/__tests__/install-deps.test.ts | 17 +++++++++++++++++ packages/cli/src/runtime-versions.ts | 7 +++++++ 3 files changed, 32 insertions(+) diff --git a/packages/cli/src/__tests__/runtime-versions.test.ts b/packages/cli/src/__tests__/runtime-versions.test.ts index 005db867..700fef72 100644 --- a/packages/cli/src/__tests__/runtime-versions.test.ts +++ b/packages/cli/src/__tests__/runtime-versions.test.ts @@ -116,6 +116,14 @@ describe('compareVersions', () => { expect(compareVersions('1.0.0-rc.1', '1.0.0-rc.1')).toBe(0) }) + it('treats a non-numeric core as not comparable (never "ahead")', () => { + // A corrupt manifest version must not NaN-poison the order into 1 + // ("installed is newer") — that would suppress the align guidance. + 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) + }) + 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 20e96117..bc11ea7d 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 @@ -316,6 +316,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 }) diff --git a/packages/cli/src/runtime-versions.ts b/packages/cli/src/runtime-versions.ts index f64bb180..67e4d436 100644 --- a/packages/cli/src/runtime-versions.ts +++ b/packages/cli/src/runtime-versions.ts @@ -103,6 +103,12 @@ export function pinnedSpec( * 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 whose core isn't numeric (`v1.0.0`, `1.0.x`, garbage from a + * corrupt manifest) is NOT COMPARABLE: return `0` rather than a + * NaN-poisoned 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". */ export function compareVersions(a: string, b: string): -1 | 0 | 1 { const parse = (v: string) => { @@ -114,6 +120,7 @@ export function compareVersions(a: string, b: string): -1 | 0 | 1 { } const pa = parse(a) const pb = parse(b) + if (pa.core.some(Number.isNaN) || pb.core.some(Number.isNaN)) return 0 for (let i = 0; i < 3; i++) { const da = pa.core[i] ?? 0 const db = pb.core[i] ?? 0 From daa25b89c896116ee10a7c78a20873022ccc00a8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 16 Jul 2026 17:45:16 +1000 Subject: [PATCH 3/3] fix(cli): close the review findings on the release-train coupling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven findings from the code-review pass, all addressed: 1. compareVersions gets a strict shape gate (^\d+\.\d+\.\d+ with optional well-formed prerelease; build metadata rejected): empty prerelease ('1.0.0-'), '+metadata', truncated/extra cores, and parseInt trailing garbage ('9.9.10rc') all previously slipped the NaN-only guard and could classify a malformed install as 'ahead', suppressing the reinstall guidance. One gate now encodes the whole not-comparable policy; the NaN check is subsumed. Test matrix covers every class. 2. Mixed ahead+missing no longer silently manufactures a cross-train mismatch: when missing packages are about to be installed beside newer ones, the warning says the pairing may not match and to update stash first. 3. The ahead warning prints the exact update command (devInstallCommand of stash@) — lockstep guarantees that release exists — instead of an uncommanded "matching release". 4. @cipherstash/prisma-next JOINS the fixed group (per Dan: the prisma-next package should line up to the train version too). The changeset's "can never go stale" claim is now actually true — no residual exclusion. 5. release-train.test.ts gains the growth guard: the changesets fixed group must equal RELEASE_TRAIN_MANIFESTS exactly, so a future train package can't silently version outside the lockstep. 6. skills/stash-cli skew sentence is direction-aware (align when older; update stash — never downgrade — when newer), fixing the now-wrong unconditional "align versions before continuing". 7. Companion changesets for @cipherstash/wizard and @cipherstash/prisma-next so their CHANGELOGs explain the version-line jump onto the train. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .changeset/config.json | 1 + .changeset/prisma-next-joins-release-train.md | 9 +++++ .changeset/release-train-coupling.md | 24 +++++++------ .changeset/wizard-joins-release-train.md | 9 +++++ .../cli/src/__tests__/release-train.test.ts | 17 ++++++++++ .../src/__tests__/runtime-versions.test.ts | 20 +++++++++-- .../init/steps/__tests__/install-deps.test.ts | 34 +++++++++++++++++++ .../src/commands/init/steps/install-deps.ts | 29 +++++++++++++--- packages/cli/src/runtime-versions.ts | 23 +++++++++---- skills/stash-cli/SKILL.md | 2 +- 10 files changed, 141 insertions(+), 27 deletions(-) create mode 100644 .changeset/prisma-next-joins-release-train.md create mode 100644 .changeset/wizard-joins-release-train.md diff --git a/.changeset/config.json b/.changeset/config.json index caed881d..2bacd138 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,6 +8,7 @@ "@cipherstash/stack", "@cipherstash/stack-drizzle", "@cipherstash/stack-supabase", + "@cipherstash/prisma-next", "@cipherstash/wizard" ] ], 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 index 3bc5b3dc..dffd0579 100644 --- a/.changeset/release-train-coupling.md +++ b/.changeset/release-train-coupling.md @@ -7,17 +7,19 @@ 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 says the install -is likely fine and to update the stash CLI to the matching release instead. -Unreadable manifests still count as behind (a broken install should be offered -the reinstall fix). +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/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 stack-only release would have left the -published CLI embedding — and recommending — outdated versions). -`@cipherstash/prisma-next` stays on its own version line by design; the -direction-aware messaging above covers it. +`@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 700fef72..20f35dea 100644 --- a/packages/cli/src/__tests__/runtime-versions.test.ts +++ b/packages/cli/src/__tests__/runtime-versions.test.ts @@ -116,12 +116,26 @@ describe('compareVersions', () => { expect(compareVersions('1.0.0-rc.1', '1.0.0-rc.1')).toBe(0) }) - it('treats a non-numeric core as not comparable (never "ahead")', () => { - // A corrupt manifest version must not NaN-poison the order into 1 - // ("installed is newer") — that would suppress the align guidance. + 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', () => { 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 bc11ea7d..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 @@ -234,12 +237,43 @@ describe('installDepsStep', () => { '@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') diff --git a/packages/cli/src/commands/init/steps/install-deps.ts b/packages/cli/src/commands/init/steps/install-deps.ts index 7043e67d..0853d73b 100644 --- a/packages/cli/src/commands/init/steps/install-deps.ts +++ b/packages/cli/src/commands/init/steps/install-deps.ts @@ -12,6 +12,7 @@ import { CancelledError } from '../types.js' import { combinedInstallCommands, detectPackageManager, + devInstallCommand, installedVersion, isPackageInstalled, } from '../utils.js' @@ -191,11 +192,6 @@ export const installDepsStep: InitStep = { if (skewed.length > 0) { p.log.warn(`Version skew detected:\n ${skewLines(skewed)}`) } - if (ahead.length > 0) { - 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.`, - ) - } // What's missing outright (pinned, prod/dev split). const missing: string[] = [] @@ -204,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 67e4d436..9e28b84c 100644 --- a/packages/cli/src/runtime-versions.ts +++ b/packages/cli/src/runtime-versions.ts @@ -104,13 +104,22 @@ export function pinnedSpec( * (`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 whose core isn't numeric (`v1.0.0`, `1.0.x`, garbage from a - * corrupt manifest) is NOT COMPARABLE: return `0` rather than a - * NaN-poisoned order. Callers classify non-ahead as behind, so an - * unparseable installed version gets the safe treatment (align/reinstall + * 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 { @@ -120,10 +129,10 @@ export function compareVersions(a: string, b: string): -1 | 0 | 1 { } const pa = parse(a) const pb = parse(b) - if (pa.core.some(Number.isNaN) || pb.core.some(Number.isNaN)) return 0 for (let i = 0; i < 3; i++) { - const da = pa.core[i] ?? 0 - const db = pb.core[i] ?? 0 + // 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. 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:**