Skip to content

Commit 4f417d7

Browse files
committed
fix(create-objectstack, cli): pin scaffolded @objectstack/* deps to the exact scaffolder version
create-objectstack, os init and os create (standalone) wrote ^<version> for every @objectstack/* dependency of a new project, the range the v18 docs say can move a project onto a breaking release. They now write the scaffolder's own version exactly, prereleases verbatim; os create --in-repo keeps workspace:*. Claude-Session: https://claude.ai/code/session_01CxBKycaiUpA1yGx1XyoGqX Co-authored-by: Claude <noreply@anthropic.com>
1 parent dc3e5b9 commit 4f417d7

9 files changed

Lines changed: 226 additions & 153 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'create-objectstack': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
fix(create-objectstack, cli): a scaffolded project pins every `@objectstack/*` dependency to the exact version of the tool that wrote it, not a `^` range
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
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.
12+
13+
- **`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`).
14+
- **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.
15+
- **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.

‎packages/cli/src/commands/create.ts‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,12 @@
9393
* ## The version the standalone shape pins
9494
*
9595
* Every `@objectstack/*` package in this monorepo is released together on one
96-
* version, so the range that is guaranteed to exist and to be mutually
97-
* compatible is the CLI's own. `getCliVersion()` (owned by `init.ts`, which
98-
* has pinned scaffolded deps this way since long before this command did) reads
99-
* it from the CLI package's own manifest; the range is imported rather than
100-
* re-derived so the two scaffolders cannot drift on the one value that decides
101-
* whether a scaffold resolves at all.
96+
* version, so the version that is guaranteed to exist and to be mutually
97+
* compatible is the CLI's own, pinned exactly. `getCliVersion()` (owned by
98+
* `init.ts`, which has pinned scaffolded deps this way since long before this
99+
* command did) reads it from the CLI package's own manifest; the version is
100+
* imported rather than re-derived so the two scaffolders cannot drift on the
101+
* one value that decides whether a scaffold resolves at all.
102102
*
103103
* ## Why the standalone shape reuses `init`'s renderers
104104
*
@@ -157,12 +157,14 @@ export const DEFAULT_PLACEMENT: ScaffoldPlacement = 'standalone';
157157
/**
158158
* The dependency spec every `@objectstack/*` entry in an emitted manifest gets.
159159
*
160-
* `standalone` is caret-pinned to the CLI's own version — a published range
161-
* that npm, pnpm, yarn and bun all resolve. `workspace:*` is emitted ONLY for
162-
* the in-repo placement, where a workspace really does supply those names.
160+
* `standalone` is the CLI's own version, EXACT (no `^`/`~`, a prerelease kept
161+
* verbatim) — a published version that npm, pnpm, yarn and bun all resolve,
162+
* and the pin the docs prescribe, because a breaking change may ship in a
163+
* minor or a patch on the v18 line. `workspace:*` is emitted ONLY for the
164+
* in-repo placement, where a workspace really does supply those names.
163165
*/
164166
export function objectstackDependencySpec(placement: ScaffoldPlacement): string {
165-
return placement === 'in-repo' ? 'workspace:*' : `^${getCliVersion()}`;
167+
return placement === 'in-repo' ? 'workspace:*' : getCliVersion();
166168
}
167169

168170
/**

‎packages/cli/src/commands/init.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,13 @@ export function getCliVersion(): string {
4949
return cachedCliVersion;
5050
}
5151

52-
/** Caret-pinned to the CLI's own version (e.g. `^6.5.0`). */
52+
/**
53+
* The CLI's own version, EXACT (e.g. `18.0.0`, or `18.1.0-rc.2` in an RC
54+
* window) — never a `^`/`~` range. A breaking change may ship in a minor or a
55+
* patch on the v18 line, and the docs tell a project to pin exact versions.
56+
*/
5357
function pkgVersion(): string {
54-
return `^${getCliVersion()}`;
58+
return getCliVersion();
5559
}
5660

5761
/**

‎packages/cli/test/create.test.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,14 @@ describe('os create: the sweep covers every shipped template', () => {
120120
});
121121

122122
describe.each(TEMPLATE_KEYS)('os create %s — the standalone (default) emission', (key) => {
123-
it('declares every @objectstack dependency as a published range pinned to this CLI', () => {
123+
it('declares every @objectstack dependency at the exact version of this CLI', () => {
124124
const deps = objectstackDeps(pkgJson(key, 'standalone'));
125125
expect(Object.keys(deps).length).toBeGreaterThan(0);
126126
for (const [name, spec] of Object.entries(deps)) {
127-
expect(spec, `${key}: ${name}`).toBe(`^${getCliVersion()}`);
128-
// A published range, spelled the way npm/pnpm/yarn/bun all resolve it.
129-
expect(spec, `${key}: ${name}`).toMatch(/^\^\d+\.\d+\.\d+/);
127+
expect(spec, `${key}: ${name}`).toBe(getCliVersion());
128+
// A published version with no range operator: a breaking change may ship
129+
// in a minor or a patch on the v18 line, so the docs prescribe exact pins.
130+
expect(spec, `${key}: ${name}`).toMatch(/^\d+\.\d+\.\d+/);
130131
}
131132
});
132133

‎packages/cli/test/init.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ describe('init command — published scaffold', () => {
2828
}
2929
});
3030

31-
it('pins every @objectstack/* dep to the CLI version', () => {
32-
const expected = `^${pkg.version}`;
31+
// EXACT, no `^`/`~`: a breaking change may ship in a minor or a patch on
32+
// the v18 line, and the docs tell a project to pin exact versions.
33+
it('pins every @objectstack/* dep to the exact CLI version', () => {
34+
const expected = pkg.version;
3335
for (const [name, range] of Object.entries(allDeps)) {
3436
if (name.startsWith('@objectstack/')) {
3537
expect(range, name).toBe(expected);
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os init` and `os create` (standalone) pin every `@objectstack/*` dependency
5+
* of the project they emit to the CLI's own version, EXACTLY — no `^`, no `~`.
6+
* On the v18 line a breaking change may ship in a minor or a patch, and the
7+
* docs tell a project to pin exact versions; a caret would let the next
8+
* re-resolve move a fresh project across such a release unasked.
9+
*
10+
* `init.test.ts` and `create.test.ts` pin that against the version this
11+
* checkout carries. This file pins the shape the NEXT version pass produces
12+
* and no other test sees until it lands: a `cut-rc` pass in pre mode makes
13+
* the CLI `X.Y.Z-rc.N`, and that whole string — prerelease tail included — is
14+
* the pin. The CLI reads its version from its own `package.json` once and
15+
* caches it, so the module graph is re-imported under a planted manifest read.
16+
* The in-repo placement is the control: it keeps `workspace:*`.
17+
*/
18+
19+
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
20+
import fs from 'fs';
21+
import path from 'path';
22+
import { fileURLToPath } from 'url';
23+
24+
const HERE = path.dirname(fileURLToPath(import.meta.url));
25+
const CLI_MANIFEST = path.resolve(HERE, '..', 'package.json');
26+
const PRERELEASE = '18.1.0-rc.2';
27+
28+
type InitModule = typeof import('../src/commands/init.js');
29+
type CreateModule = typeof import('../src/commands/create.js');
30+
31+
let init: InitModule;
32+
let create: CreateModule;
33+
34+
/** Every `@objectstack/*` entry of a manifest, both dependency sections. */
35+
function objectstackDeps(pkg: {
36+
dependencies?: Record<string, string>;
37+
devDependencies?: Record<string, string>;
38+
}): [string, string][] {
39+
return Object.entries({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }).filter(
40+
([name]) => name.startsWith('@objectstack/'),
41+
);
42+
}
43+
44+
beforeAll(async () => {
45+
const realRead = fs.readFileSync;
46+
const spy = vi.spyOn(fs, 'readFileSync').mockImplementation(((file: unknown, ...rest: unknown[]) =>
47+
typeof file === 'string' && path.resolve(file) === CLI_MANIFEST
48+
? JSON.stringify({ name: '@objectstack/cli', version: PRERELEASE })
49+
: (realRead as (...args: unknown[]) => unknown)(file, ...rest)) as typeof fs.readFileSync);
50+
try {
51+
vi.resetModules();
52+
init = await import('../src/commands/init.js');
53+
create = await import('../src/commands/create.js');
54+
// Read while the planted manifest is in place; the value is cached after.
55+
expect(init.getCliVersion()).toBe(PRERELEASE);
56+
} finally {
57+
spy.mockRestore();
58+
}
59+
});
60+
61+
afterAll(() => {
62+
vi.resetModules();
63+
});
64+
65+
describe('a prerelease CLI pins scaffolded @objectstack/* deps exactly', () => {
66+
it('os init: every template, every @objectstack/* dep', () => {
67+
const keys = Object.keys(init.TEMPLATES);
68+
expect(keys.length).toBeGreaterThan(0);
69+
for (const key of keys) {
70+
const deps = objectstackDeps(init.TEMPLATES[key]);
71+
expect(deps.length, key).toBeGreaterThan(0);
72+
for (const [name, spec] of deps) expect(spec, `${key}: ${name}`).toBe(PRERELEASE);
73+
}
74+
});
75+
76+
it('os create (standalone): every template, every @objectstack/* dep', () => {
77+
expect(create.objectstackDependencySpec('standalone')).toBe(PRERELEASE);
78+
const keys = Object.keys(create.templates);
79+
expect(keys.length).toBeGreaterThan(0);
80+
for (const key of keys) {
81+
const pkg = create.templates[key].filesFor('standalone')['package.json']('my-thing') as {
82+
dependencies?: Record<string, string>;
83+
devDependencies?: Record<string, string>;
84+
};
85+
const deps = objectstackDeps(pkg);
86+
expect(deps.length, key).toBeGreaterThan(0);
87+
for (const [name, spec] of deps) expect(spec, `${key}: ${name}`).toBe(PRERELEASE);
88+
}
89+
});
90+
91+
it('os create --in-repo keeps workspace:* (the control)', () => {
92+
expect(create.objectstackDependencySpec('in-repo')).toBe('workspace:*');
93+
for (const key of Object.keys(create.templates)) {
94+
const pkg = create.templates[key].filesFor('in-repo')['package.json']('my-thing') as {
95+
dependencies?: Record<string, string>;
96+
devDependencies?: Record<string, string>;
97+
};
98+
for (const [name, spec] of objectstackDeps(pkg)) {
99+
expect(spec, `${key}: ${name}`).toBe('workspace:*');
100+
}
101+
}
102+
});
103+
});

‎packages/create-objectstack/src/pkg-utils.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@
55
// test needs must be importable without running the CLI.
66

77
/**
8-
* Rewrite every `@objectstack/*` range in dependencies/devDependencies to
9-
* `^<version>`. All @objectstack packages version in lockstep, so the
10-
* scaffolder's own version always resolves and always matches the framework
11-
* the docs describe. No-op when the version is unknown (`0.0.0` fallback) so
12-
* a broken version read never corrupts the template's committed baseline.
8+
* Rewrite every `@objectstack/*` range in dependencies/devDependencies to the
9+
* EXACT `<version>` — no `^`, no `~`, prereleases kept verbatim. On the v18
10+
* line a breaking change may ship in a minor or a patch, so the docs tell a
11+
* project to pin exact versions; a caret here would let the next re-resolve
12+
* (`npm update`, a deleted lockfile, an install without the lock) move a new
13+
* project across such a release without anyone deciding to upgrade. All
14+
* @objectstack packages version in lockstep (the changesets `fixed` group),
15+
* so the scaffolder's own version is published for every one of them. No-op
16+
* when the version is unknown (`0.0.0` fallback) so a broken version read
17+
* never corrupts the template's committed baseline.
1318
*/
1419
export function syncObjectStackDeps(
1520
pkg: { dependencies?: Record<string, string>; devDependencies?: Record<string, string> },
@@ -20,7 +25,7 @@ export function syncObjectStackDeps(
2025
if (!deps) continue;
2126
for (const dep of Object.keys(deps)) {
2227
if (dep.startsWith('@objectstack/')) {
23-
deps[dep] = `^${version}`;
28+
deps[dep] = version;
2429
}
2530
}
2631
}

0 commit comments

Comments
 (0)