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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/25510-scaffold-exact-objectstack-pins.md
Original file line number Diff line number Diff line change
@@ -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 `^<version>` 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.
22 changes: 12 additions & 10 deletions packages/cli/src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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();
}

/**
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand Down
9 changes: 5 additions & 4 deletions packages/cli/test/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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+/);
}
});

Expand Down
6 changes: 4 additions & 2 deletions packages/cli/test/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
103 changes: 103 additions & 0 deletions packages/cli/test/scaffold-exact-objectstack-pin.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
devDependencies?: Record<string, string>;
}): [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<string, string>;
devDependencies?: Record<string, string>;
};
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<string, string>;
devDependencies?: Record<string, string>;
};
for (const [name, spec] of objectstackDeps(pkg)) {
expect(spec, `${key}: ${name}`).toBe('workspace:*');
}
}
});
});
17 changes: 11 additions & 6 deletions packages/create-objectstack/src/pkg-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@
// test needs must be importable without running the CLI.

/**
* Rewrite every `@objectstack/*` range in dependencies/devDependencies to
* `^<version>`. 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 `<version>` — 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<string, string>; devDependencies?: Record<string, string> },
Expand All @@ -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;
}
}
}
Expand Down
Loading
Loading