diff --git a/.changeset/25510-scaffold-exact-objectstack-pins.md b/.changeset/25510-scaffold-exact-objectstack-pins.md new file mode 100644 index 00000000000..70ea67c2b6c --- /dev/null +++ b/.changeset/25510-scaffold-exact-objectstack-pins.md @@ -0,0 +1,15 @@ +--- +'create-objectstack': patch +'@objectstack/cli': patch +--- + +fix(create-objectstack, cli): a scaffolded project pins every `@objectstack/*` dependency to the exact version of the tool that wrote it, not a `^` range + +Clause-②: no +Metadata-protocol: none + +On the v18 line a breaking change may ship in a minor or a patch, and the release notes say "Pin exact versions". The scaffolders wrote `^` instead, so the next re-resolve (`npm update`, a deleted lockfile, an install without the lock) could move a new project onto a release that breaks it without anyone deciding to upgrade. + +- **`npm create objectstack`** (`create-objectstack`), **`os init`** and **`os create`** (the default standalone placement) now write the scaffolder's own version, exactly, for every `@objectstack/*` entry in the emitted `package.json`: `18.0.0`, not `^18.0.0`. A prerelease is written verbatim (`18.1.0-rc.2`). +- **Unchanged.** `os create --in-repo` still writes `workspace:*`. Third-party ranges (`typescript`, `vitest`, `zod`, `@types/node`) are not touched. Every `@objectstack/*` package is released on one version, so the exact version is published for each of them. +- **An existing project** keeps whatever its `package.json` says. To follow the docs, replace each `^18.x.y` on an `@objectstack/*` entry with the exact version you run (for example `"@objectstack/runtime": "18.0.0"`), then reinstall. diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index a753ba6e2d6..1cad9883fd5 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -93,12 +93,12 @@ * ## The version the standalone shape pins * * Every `@objectstack/*` package in this monorepo is released together on one - * version, so the range that is guaranteed to exist and to be mutually - * compatible is the CLI's own. `getCliVersion()` (owned by `init.ts`, which - * has pinned scaffolded deps this way since long before this command did) reads - * it from the CLI package's own manifest; the range is imported rather than - * re-derived so the two scaffolders cannot drift on the one value that decides - * whether a scaffold resolves at all. + * version, so the version that is guaranteed to exist and to be mutually + * compatible is the CLI's own, pinned exactly. `getCliVersion()` (owned by + * `init.ts`, which has pinned scaffolded deps this way since long before this + * command did) reads it from the CLI package's own manifest; the version is + * imported rather than re-derived so the two scaffolders cannot drift on the + * one value that decides whether a scaffold resolves at all. * * ## Why the standalone shape reuses `init`'s renderers * @@ -157,12 +157,14 @@ export const DEFAULT_PLACEMENT: ScaffoldPlacement = 'standalone'; /** * The dependency spec every `@objectstack/*` entry in an emitted manifest gets. * - * `standalone` is caret-pinned to the CLI's own version — a published range - * that npm, pnpm, yarn and bun all resolve. `workspace:*` is emitted ONLY for - * the in-repo placement, where a workspace really does supply those names. + * `standalone` is the CLI's own version, EXACT (no `^`/`~`, a prerelease kept + * verbatim) — a published version that npm, pnpm, yarn and bun all resolve, + * and the pin the docs prescribe, because a breaking change may ship in a + * minor or a patch on the v18 line. `workspace:*` is emitted ONLY for the + * in-repo placement, where a workspace really does supply those names. */ export function objectstackDependencySpec(placement: ScaffoldPlacement): string { - return placement === 'in-repo' ? 'workspace:*' : `^${getCliVersion()}`; + return placement === 'in-repo' ? 'workspace:*' : getCliVersion(); } /** diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 73c71be9fc9..36e32addae0 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -49,9 +49,13 @@ export function getCliVersion(): string { return cachedCliVersion; } -/** Caret-pinned to the CLI's own version (e.g. `^6.5.0`). */ +/** + * The CLI's own version, EXACT (e.g. `18.0.0`, or `18.1.0-rc.2` in an RC + * window) — never a `^`/`~` range. A breaking change may ship in a minor or a + * patch on the v18 line, and the docs tell a project to pin exact versions. + */ function pkgVersion(): string { - return `^${getCliVersion()}`; + return getCliVersion(); } /** diff --git a/packages/cli/test/create.test.ts b/packages/cli/test/create.test.ts index bea9532ee64..89512dc364b 100644 --- a/packages/cli/test/create.test.ts +++ b/packages/cli/test/create.test.ts @@ -120,13 +120,14 @@ describe('os create: the sweep covers every shipped template', () => { }); describe.each(TEMPLATE_KEYS)('os create %s — the standalone (default) emission', (key) => { - it('declares every @objectstack dependency as a published range pinned to this CLI', () => { + it('declares every @objectstack dependency at the exact version of this CLI', () => { const deps = objectstackDeps(pkgJson(key, 'standalone')); expect(Object.keys(deps).length).toBeGreaterThan(0); for (const [name, spec] of Object.entries(deps)) { - expect(spec, `${key}: ${name}`).toBe(`^${getCliVersion()}`); - // A published range, spelled the way npm/pnpm/yarn/bun all resolve it. - expect(spec, `${key}: ${name}`).toMatch(/^\^\d+\.\d+\.\d+/); + expect(spec, `${key}: ${name}`).toBe(getCliVersion()); + // A published version with no range operator: a breaking change may ship + // in a minor or a patch on the v18 line, so the docs prescribe exact pins. + expect(spec, `${key}: ${name}`).toMatch(/^\d+\.\d+\.\d+/); } }); diff --git a/packages/cli/test/init.test.ts b/packages/cli/test/init.test.ts index db1a7608e99..45f40a7a9c3 100644 --- a/packages/cli/test/init.test.ts +++ b/packages/cli/test/init.test.ts @@ -28,8 +28,10 @@ describe('init command — published scaffold', () => { } }); - it('pins every @objectstack/* dep to the CLI version', () => { - const expected = `^${pkg.version}`; + // EXACT, no `^`/`~`: a breaking change may ship in a minor or a patch on + // the v18 line, and the docs tell a project to pin exact versions. + it('pins every @objectstack/* dep to the exact CLI version', () => { + const expected = pkg.version; for (const [name, range] of Object.entries(allDeps)) { if (name.startsWith('@objectstack/')) { expect(range, name).toBe(expected); diff --git a/packages/cli/test/scaffold-exact-objectstack-pin.test.ts b/packages/cli/test/scaffold-exact-objectstack-pin.test.ts new file mode 100644 index 00000000000..8dcde37ad06 --- /dev/null +++ b/packages/cli/test/scaffold-exact-objectstack-pin.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os init` and `os create` (standalone) pin every `@objectstack/*` dependency + * of the project they emit to the CLI's own version, EXACTLY — no `^`, no `~`. + * On the v18 line a breaking change may ship in a minor or a patch, and the + * docs tell a project to pin exact versions; a caret would let the next + * re-resolve move a fresh project across such a release unasked. + * + * `init.test.ts` and `create.test.ts` pin that against the version this + * checkout carries. This file pins the shape the NEXT version pass produces + * and no other test sees until it lands: a `cut-rc` pass in pre mode makes + * the CLI `X.Y.Z-rc.N`, and that whole string — prerelease tail included — is + * the pin. The CLI reads its version from its own `package.json` once and + * caches it, so this file first imports the commands under a planted manifest read. + * The in-repo placement is the control: it keeps `workspace:*`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const CLI_MANIFEST = path.resolve(HERE, '..', 'package.json'); +const PRERELEASE = '18.1.0-rc.2'; + +/** Every `@objectstack/*` entry of a manifest, both dependency sections. */ +function objectstackDeps(pkg: { + dependencies?: Record; + devDependencies?: Record; +}): [string, string][] { + return Object.entries({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }).filter( + ([name]) => name.startsWith('@objectstack/'), + ); +} + +// The first import of the command modules happens HERE, under the planted +// manifest read, at collection time: each test file gets its own module +// registry, so no reset is needed, and module evaluation is not charged to a +// hook's timeout. +const realRead = fs.readFileSync; +const spy = vi.spyOn(fs, 'readFileSync').mockImplementation(((file: unknown, ...rest: unknown[]) => + typeof file === 'string' && path.resolve(file) === CLI_MANIFEST + ? JSON.stringify({ name: '@objectstack/cli', version: PRERELEASE }) + : (realRead as (...args: unknown[]) => unknown)(file, ...rest)) as typeof fs.readFileSync); +let init: typeof import('../src/commands/init.js'); +let create: typeof import('../src/commands/create.js'); +let plantedVersion: string; +try { + init = await import('../src/commands/init.js'); + create = await import('../src/commands/create.js'); + // Read while the planted manifest is in place; the value is cached after. + plantedVersion = init.getCliVersion(); +} finally { + spy.mockRestore(); +} + +describe('a prerelease CLI pins scaffolded @objectstack/* deps exactly', () => { + // Harness guard: without it, a manifest read that escaped the spy would + // measure the checkout's own release version and prove nothing about tails. + it('reads the planted prerelease as the CLI version', () => { + expect(plantedVersion).toBe(PRERELEASE); + }); + + it('os init: every template, every @objectstack/* dep', () => { + const keys = Object.keys(init.TEMPLATES); + expect(keys.length).toBeGreaterThan(0); + for (const key of keys) { + const deps = objectstackDeps(init.TEMPLATES[key]); + expect(deps.length, key).toBeGreaterThan(0); + for (const [name, spec] of deps) expect(spec, `${key}: ${name}`).toBe(PRERELEASE); + } + }); + + it('os create (standalone): every template, every @objectstack/* dep', () => { + expect(create.objectstackDependencySpec('standalone')).toBe(PRERELEASE); + const keys = Object.keys(create.templates); + expect(keys.length).toBeGreaterThan(0); + for (const key of keys) { + const pkg = create.templates[key].filesFor('standalone')['package.json']('my-thing') as { + dependencies?: Record; + devDependencies?: Record; + }; + const deps = objectstackDeps(pkg); + expect(deps.length, key).toBeGreaterThan(0); + for (const [name, spec] of deps) expect(spec, `${key}: ${name}`).toBe(PRERELEASE); + } + }); + + it('os create --in-repo keeps workspace:* (the control)', () => { + expect(create.objectstackDependencySpec('in-repo')).toBe('workspace:*'); + for (const key of Object.keys(create.templates)) { + const pkg = create.templates[key].filesFor('in-repo')['package.json']('my-thing') as { + dependencies?: Record; + devDependencies?: Record; + }; + for (const [name, spec] of objectstackDeps(pkg)) { + expect(spec, `${key}: ${name}`).toBe('workspace:*'); + } + } + }); +}); diff --git a/packages/create-objectstack/src/pkg-utils.ts b/packages/create-objectstack/src/pkg-utils.ts index f6f1959ebeb..0e2113addc5 100644 --- a/packages/create-objectstack/src/pkg-utils.ts +++ b/packages/create-objectstack/src/pkg-utils.ts @@ -5,11 +5,16 @@ // test needs must be importable without running the CLI. /** - * Rewrite every `@objectstack/*` range in dependencies/devDependencies to - * `^`. All @objectstack packages version in lockstep, so the - * scaffolder's own version always resolves and always matches the framework - * the docs describe. No-op when the version is unknown (`0.0.0` fallback) so - * a broken version read never corrupts the template's committed baseline. + * Rewrite every `@objectstack/*` range in dependencies/devDependencies to the + * EXACT `` — no `^`, no `~`, prereleases kept verbatim. On the v18 + * line a breaking change may ship in a minor or a patch, so the docs tell a + * project to pin exact versions; a caret here would let the next re-resolve + * (`npm update`, a deleted lockfile, an install without the lock) move a new + * project across such a release without anyone deciding to upgrade. All + * @objectstack packages version in lockstep (the changesets `fixed` group), + * so the scaffolder's own version is published for every one of them. No-op + * when the version is unknown (`0.0.0` fallback) so a broken version read + * never corrupts the template's committed baseline. */ export function syncObjectStackDeps( pkg: { dependencies?: Record; devDependencies?: Record }, @@ -20,7 +25,7 @@ export function syncObjectStackDeps( if (!deps) continue; for (const dep of Object.keys(deps)) { if (dep.startsWith('@objectstack/')) { - deps[dep] = `^${version}`; + deps[dep] = version; } } } diff --git a/packages/create-objectstack/src/runtime-image.test.ts b/packages/create-objectstack/src/runtime-image.test.ts index de6880de6d4..c18ed21ac6e 100644 --- a/packages/create-objectstack/src/runtime-image.test.ts +++ b/packages/create-objectstack/src/runtime-image.test.ts @@ -37,82 +37,19 @@ function runtimeTag(dir: string): string | undefined { return line?.split(':').pop()?.trim(); } -/** The `@objectstack/cli` range of a scaffolded package.json. */ -function cliRange(dir: string): string { +/** The `@objectstack/cli` pin of a scaffolded package.json. */ +function cliPin(dir: string): string { const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); return pkg.devDependencies['@objectstack/cli']; } -/** - * semver precedence of two prerelease tails (`rc.2` vs `rc.10`); `undefined` - * is a release, which outranks every prerelease of the same `X.Y.Z`. - */ -function comparePrerelease(a: string | undefined, b: string | undefined): number { - if (a === b) return 0; - if (a === undefined) return 1; - if (b === undefined) return -1; - const as = a.split('.'); - const bs = b.split('.'); - for (let i = 0; i < Math.max(as.length, bs.length); i++) { - if (as[i] === undefined) return -1; - if (bs[i] === undefined) return 1; - if (as[i] === bs[i]) continue; - const aNum = /^\d+$/.test(as[i]); - const bNum = /^\d+$/.test(bs[i]); - if (aNum && bNum) return Number(as[i]) - Number(bs[i]); - if (aNum !== bNum) return aNum ? -1 : 1; - return as[i] < bs[i] ? -1 : 1; - } - return 0; -} - -/** - * Does `version` satisfy the caret `range` the scaffolder writes? Deliberately - * a satisfies-check and not an equality one: the emitted package.json carries - * `^X.Y.Z` and the tag names the version npm RESOLVED inside that range, so - * equality would be the wrong agreement predicate and would pass only by - * accident on a freshly published major. - * - * The floor may carry a prerelease tail. The scaffolder writes `^`, and a `cut-rc` version pass in pre mode makes that version - * `18.0.0-rc.N`, so the range becomes `^18.0.0-rc.N`. Caret semantics are - * semver's: the same major and at or above the floor, where a release outranks - * its own prereleases, so `18.1.2` satisfies `^18.0.0-rc.1`. A prerelease - * VERSION is admitted only on a prerelease floor's own `X.Y.Z`, so - * `18.1.0-rc.1` does not. - * - * A range it cannot read THROWS rather than answering `false`: a `false` here - * reads as the scaffolder's or the fixture's fault, which is how a range shape - * this helper did not model once failed on the release PR. Majors of 1 and up - * only; the scaffolder writes no range for a `0.x` version. - */ -function satisfiesCaret(range: string, version: string): boolean { - const r = /^\^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(range); - if (!r || r[1] === '0') { - throw new Error( - `satisfiesCaret reads ^MAJOR.MINOR.PATCH[-PRERELEASE] with MAJOR >= 1, got "${range}"`, - ); - } - const v = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version); - if (!v) return false; - const [rMaj, rMin, rPat] = r.slice(1, 4).map(Number); - const [vMaj, vMin, vPat] = v.slice(1, 4).map(Number); - const rPre = r[4]; - const vPre = v[4]; - if (vMaj !== rMaj) return false; - if (vPre !== undefined && (rPre === undefined || vMin !== rMin || vPat !== rPat)) return false; - if (vMin !== rMin) return vMin > rMin; - if (vPat !== rPat) return vPat > rPat; - return comparePrerelease(vPre, rPre) >= 0; -} - /** Scaffold the blank template the way index.ts does, up to the install. */ -function scaffold(dir: string): void { +function scaffold(dir: string, version: string = ownVersion): void { copyDir(blankTemplate, dir, []); const pkgPath = path.join(dir, 'package.json'); const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); pkg.name = 'my-app'; - syncObjectStackDeps(pkg, ownVersion); + syncObjectStackDeps(pkg, version); fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); } @@ -137,17 +74,10 @@ describe('scaffolded Dockerfile runtime image tag (#9017)', () => { }); describe('with dependencies installed (the default path)', () => { - // The version npm resolved is deliberately NOT the range's floor: that is - // the normal case (`^17.0.0` installs the newest 17.x) and it is the case - // that would go unnoticed if the tag were taken from package.json instead. - // - // It is DERIVED from the scaffolder's own version — same major, next minor - // — never spelled as a literal. The scaffolder writes `^`, so a - // literal `.4.2` lies inside that range only while ownVersion's minor - // is at most 4; the version pass to 17.5.0 put it BELOW the floor, and the - // agreement test went red on a fixture defect while blaming the scaffolder. - const [ownMajor, ownMinor] = String(ownVersion).split('.').map(Number); - const resolved = `${ownMajor}.${ownMinor + 1}.2`; + // The scaffolder pins every @objectstack/* dep to its OWN version, exactly + // (no `^`/`~`), so the version an install resolves IS that pin — and the + // agreement predicate between the two emitted files is equality. + const resolved = ownVersion; beforeEach(() => { scaffold(dir); @@ -156,32 +86,28 @@ describe('scaffolded Dockerfile runtime image tag (#9017)', () => { expect(result.pinned, 'pinRuntimeImage refused the scaffolded Dockerfile').toBe(true); }); - // A precondition on the fixture, not on the scaffolder: should the planted - // version ever leave the range (or land on its floor), this names the - // fixture as the fault instead of the agreement test below reporting a - // runtime-image mismatch the scaffolder never produced. - it('plants a resolved version inside the scaffolded range and above its floor', () => { - expect(resolved).not.toBe(ownVersion); - expect( - satisfiesCaret(`^${ownVersion}`, resolved), - `fixture defect: the planted @objectstack/cli ${resolved} does not ` + - `satisfy ^${ownVersion}, the range the scaffolder writes for its own version`, - ).toBe(true); + // A precondition on the fixture: the planted install is what the emitted + // exact pin resolves to, so a mismatch below names the scaffolder, never + // the fixture. + it('pins @objectstack/cli to the scaffolder\'s exact version, which the install resolves', () => { + expect(cliPin(dir)).toBe(ownVersion); + expect(cliPin(dir)).not.toMatch(/^[\^~]/); + expect(resolved).toBe(cliPin(dir)); }); it('pins the FROM tag to the @objectstack/cli the project resolved', () => { expect(runtimeTag(dir)).toBe(resolved); }); - it('emits a tag that agrees with the emitted package.json range', () => { - const range = cliRange(dir); + it('emits a tag equal to the emitted package.json pin', () => { + const pin = cliPin(dir); const tag = runtimeTag(dir)!; expect( - satisfiesCaret(range, tag), + tag, `scaffolded Dockerfile pins :${tag} but the scaffolded package.json ` + - `asks for "${range}" — the generated app would run a runtime image ` + + `pins "${pin}" — the generated app would run a runtime image ` + 'that is not the CLI building its artifact (#9017)', - ).toBe(true); + ).toBe(pin); }); it('never leaves the floating tag behind', () => { @@ -306,39 +232,26 @@ describe('scaffolded Dockerfile runtime image tag (#9017)', () => { }); }); - // The agreement predicate on its own. The tests above call it only with the - // version this checkout carries, so a range shape the NEXT version pass - // produces is exercised nowhere else until that pass lands. A `cut-rc` pass - // in pre mode is the case: the scaffolder writes `^18.0.0-rc.N`. - describe('satisfiesCaret', () => { - it.each([ - ['^18.0.0-rc.1', '18.0.0-rc.2', true], - ['^18.0.0-rc.1', '18.1.2', true], - ['^18.0.0-rc.1', '18.0.0', true], - ['^18.0.0-rc.1', '17.9.0', false], - ['^18.0.0-rc.1', '19.0.0', false], - ['^18.0.0-rc.1', '18.0.0-rc.0', false], - ['^18.0.0-rc.1', '18.1.0-rc.1', false], - ])('reads a prerelease floor: %s admits %s is %s', (range, version, admits) => { - expect(satisfiesCaret(range, version)).toBe(admits); - }); - - it.each([ - ['^17.4.0', '17.4.0', true], - ['^17.4.0', '17.5.2', true], - ['^17.4.0', '17.3.9', false], - ['^17.4.0', '18.0.0', false], - ])('keeps the plain floor: %s admits %s is %s', (range, version, admits) => { - expect(satisfiesCaret(range, version)).toBe(admits); - }); - - it('admits a prerelease version only on a prerelease floor', () => { - expect(satisfiesCaret('^17.4.0', '17.5.0-rc.1')).toBe(false); - }); + // The tests above scaffold with the version this checkout carries, so the + // shape the NEXT version pass produces is exercised nowhere else until that + // pass lands. A `cut-rc` pass in pre mode is the case: the scaffolder is + // `X.Y.Z-rc.N`, and that whole string is the pin. + describe('with a prerelease scaffolder', () => { + const prerelease = '18.1.0-rc.2'; + + it('pins the prerelease exactly and tags the runtime image with it', () => { + scaffold(dir, prerelease); + const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); + const stack = Object.entries({ ...pkg.dependencies, ...pkg.devDependencies }).filter( + ([name]) => name.startsWith('@objectstack/'), + ); + expect(stack.length).toBeGreaterThan(0); + for (const [name, spec] of stack) expect(spec, name).toBe(prerelease); - it('refuses a range it cannot read instead of answering false', () => { - expect(() => satisfiesCaret('~18.0.0', '18.0.1')).toThrow('"~18.0.0"'); - expect(() => satisfiesCaret('^0.2.3', '0.2.4')).toThrow('"^0.2.3"'); + installCli(dir, prerelease); + const result = pinRuntimeImage(dir, readResolvedCliVersion(dir)!); + expect(result.pinned).toBe(true); + expect(runtimeTag(dir)).toBe(cliPin(dir)); }); }); diff --git a/packages/create-objectstack/src/scaffold-skills-single-copy.test.ts b/packages/create-objectstack/src/scaffold-skills-single-copy.test.ts index cdd1bb168c2..47c68b6273c 100644 --- a/packages/create-objectstack/src/scaffold-skills-single-copy.test.ts +++ b/packages/create-objectstack/src/scaffold-skills-single-copy.test.ts @@ -360,10 +360,11 @@ describe('a scaffolded project installs the package catalog, once', () => { ]); }); - it("depends on @objectstack/skills at @objectstack/spec's range", () => { + it("depends on @objectstack/skills at @objectstack/spec's exact version", () => { const pkg = JSON.parse(fs.readFileSync(path.join(run.project, 'package.json'), 'utf8')); const spec = pkg.dependencies?.['@objectstack/spec']; - expect(spec).toMatch(/^\^\d+\./); + // An exact version, no `^`/`~`: the docs prescribe exact pins on the v18 line. + expect(spec).toMatch(/^\d+\.\d+\.\d+/); expect(pkg.devDependencies?.[SKILLS_PACKAGE]).toBe(spec); }); diff --git a/packages/create-objectstack/src/template-consistency.test.ts b/packages/create-objectstack/src/template-consistency.test.ts index 683869072f4..64282300c15 100644 --- a/packages/create-objectstack/src/template-consistency.test.ts +++ b/packages/create-objectstack/src/template-consistency.test.ts @@ -770,18 +770,46 @@ describe('skills catalog boundary', () => { }); describe('syncObjectStackDeps', () => { - it('rewrites @objectstack/* ranges in deps and devDeps', () => { + // EXACT, not `^`: on the v18 line a breaking change may ship in a + // minor or a patch, and the docs tell a project to pin exact versions. + it('pins @objectstack/* deps and devDeps to the exact version', () => { const pkg = { dependencies: { '@objectstack/spec': '^6.0.0', chalk: '^5.0.0' }, devDependencies: { '@objectstack/cli': '^6.0.0', typescript: '^6.0.0' }, }; syncObjectStackDeps(pkg, '14.7.0'); - expect(pkg.dependencies['@objectstack/spec']).toBe('^14.7.0'); - expect(pkg.devDependencies['@objectstack/cli']).toBe('^14.7.0'); + expect(pkg.dependencies['@objectstack/spec']).toBe('14.7.0'); + expect(pkg.devDependencies['@objectstack/cli']).toBe('14.7.0'); expect(pkg.dependencies.chalk).toBe('^5.0.0'); expect(pkg.devDependencies.typescript).toBe('^6.0.0'); }); + // A `cut-rc` version pass in pre mode makes the scaffolder `X.Y.Z-rc.N`; + // the prerelease tail is part of the pin, not something to strip. + it('pins a prerelease version verbatim', () => { + const pkg = { + dependencies: { '@objectstack/runtime': '^18.0.0' }, + devDependencies: { '@objectstack/cli': '~18.0.0' }, + }; + syncObjectStackDeps(pkg, '18.1.0-rc.2'); + expect(pkg.dependencies['@objectstack/runtime']).toBe('18.1.0-rc.2'); + expect(pkg.devDependencies['@objectstack/cli']).toBe('18.1.0-rc.2'); + }); + + // The real scaffold path: the committed template through the rewrite, every + // @objectstack/* entry read back, none left carrying a range operator. + it('leaves no @objectstack/* range in the scaffolded blank template', () => { + const pkg = JSON.parse( + fs.readFileSync(path.join(TEMPLATES_DIR, 'blank', 'package.json'), 'utf8'), + ); + syncObjectStackDeps(pkg, '18.2.3'); + const stack = Object.entries({ ...pkg.dependencies, ...pkg.devDependencies }).filter( + ([name]) => name.startsWith('@objectstack/'), + ); + expect(stack.length).toBeGreaterThan(0); + for (const [name, spec] of stack) expect(spec, name).toBe('18.2.3'); + }); + it('is a no-op on the 0.0.0 fallback version', () => { const pkg = { dependencies: { '@objectstack/spec': '^6.0.0' } }; syncObjectStackDeps(pkg, '0.0.0');