Skip to content
Open
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
18 changes: 18 additions & 0 deletions .changeset/scaffold-emission-policy-one-definition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@objectstack/cli": patch
---

`objectstack init` and `objectstack create` now read one emission policy instead of each restating it.

Both commands write a `tsconfig.json` and a set of third-party dependency ranges into a new project. Each had written those in its own words, and the words had come apart. Measured on the tree: the TypeScript range — the value that decides whether a scaffolded project type-checks at all — was written in six places across three scaffolders and had split into three values (`^5.3.0`, `^5.8.0`, `^6.0.0`); the vitest range into two. Dated off `git log -G` as of 2026-09-05: the two CLI values were written in the same commit and stayed apart for 210 days, and the third value is 53 days old — the bundled template landed at `^5.3.0` like the others and was moved to `^6.0.0` later, in a commit that records no reasoning about TypeScript.

The control for that reading was already in the same file: `SCAFFOLD_PNPM_RANGE` and `renderPnpmWorkspaceYaml()` are imported by the second scaffolder rather than restated, and across the same five emissions, the same window and the same authors, they had not drifted at all. So the policy moved to where those already live — `renderScaffoldTsconfig()` and one `SCAFFOLD_*_RANGE` constant per dependency, in `init.ts`, imported by `create.ts`.

Two emitted values had to survive the merge, and both are argued rather than picked:

- **TypeScript `^5.3.0`.** `TypeScript 5.3+` is already this project's published floor — `content/docs/getting-started/index.mdx` says so, and `content/docs/deployment/troubleshooting.mdx` repeats it. `^5.8.0` matched no statement anywhere, and `^5.3.0` was already what three of the five emissions carried. Measured rather than assumed: TypeScript 5.3.3 type-checks every shape these two commands emit with results identical to 6.0.3.
- **vitest `^4.0.0`.** Neither value was a recorded decision and both were written in the same commit; `^4.0.18` claimed a patch-level floor nothing justifies and was strictly the narrower of the two.

**Nothing a scaffolded project installs changes.** `^5.3.0` and `^5.8.0` both resolve to typescript 5.9.3, and `^4.0.0` and `^4.0.18` both to vitest 4.1.11 — what moves is the floor each project declares, which is a support promise, so the surviving one is the promise the docs already make. Driving all five emissions and hashing the trees before and after: every `tsconfig.json` is byte-identical, `os init -t app` and `os init -t empty` are byte-identical in full, and exactly three `package.json` files change by exactly the one line each.

`npx create-objectstack` is deliberately untouched. It cannot import from `@objectstack/cli` — the dependency edge runs the other way — and its `^6.0.0` is a different question: unifying it would change which major of TypeScript a scaffolded project installs.
88 changes: 37 additions & 51 deletions packages/cli/src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,18 @@
*
* ## Why the standalone shape reuses `init`'s renderers
*
* `renderPnpmWorkspaceYaml()` and `SCAFFOLD_PNPM_RANGE` are `init.ts`'s, and
* they are CALLED here rather than restated. A restatement is the two-producer
* defect `test/scaffold-workspace-consistency.test.ts` exists to catch, and it
* has already been paid for once in this repo: the build-approval block landed
* in one scaffold path and not the other, and one of them shipped the pre-fix
* shape for months.
* `renderPnpmWorkspaceYaml()`, `SCAFFOLD_PNPM_RANGE`, `renderScaffoldTsconfig()`
* and the `SCAFFOLD_*_RANGE` constants are `init.ts`'s, and they are CALLED here
* rather than restated. A restatement is the two-producer defect
* `test/scaffold-workspace-consistency.test.ts` exists to catch, and it has
* already been paid for twice in this repo: the build-approval block landed in
* one scaffold path and not the other, and one of them shipped the pre-fix shape
* for months; and the TypeScript range a scaffold installs was written in six
* places and split into three values — the two CLI values 210 days apart, the
* third 53 and recorded nowhere — on the value that decides whether the
* scaffold type-checks at all. The emission policy has
* one home now — see the block above `renderScaffoldPackageJson` in `init.ts`
* for the measurement and for which values survived.
*
* ## The pin
*
Expand All @@ -84,8 +90,16 @@ import {
getCliVersion,
NPM_PACKAGE_NAME_MAX_LENGTH,
renderPnpmWorkspaceYaml,
renderScaffoldTsconfig,
sanitizeNamespace,
SCAFFOLD_PNPM_RANGE,
SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY,
SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG,
SCAFFOLD_TSX_RANGE,
SCAFFOLD_TYPES_NODE_RANGE,
SCAFFOLD_TYPESCRIPT_RANGE,
SCAFFOLD_VITEST_RANGE,
SCAFFOLD_ZOD_RANGE,
validateProjectName,
} from './init.js';

Expand Down Expand Up @@ -121,22 +135,6 @@ export function rootTsconfigExtends(inRepoDir: string, projectDirName: string):
return `${'../'.repeat(depth)}tsconfig.json`;
}

/**
* The compiler options a standalone scaffold carries in full, because it
* extends nothing. Deliberately the same set `objectstack init` writes: two
* scaffolders that disagree about `moduleResolution` is a support question
* nobody can answer, and `bundler` is what resolves the `exports` subpaths
* (`@objectstack/spec/contracts`, `/kernel`) the templates import.
*/
const STANDALONE_COMPILER_OPTIONS = {
target: 'ES2022',
module: 'ESNext',
moduleResolution: 'bundler',
strict: true,
esModuleInterop: true,
skipLibCheck: true,
} as const;

/** A rendered file: JSON objects are stringified on write, strings land as-is. */
type FileRenderer = (name: string) => unknown;

Expand Down Expand Up @@ -253,26 +251,20 @@ export const templates: Record<string, CreateTemplate> = {
license: 'MIT',
dependencies: {
'@objectstack/spec': objectstackDependencySpec(placement),
zod: '^4.3.6',
zod: SCAFFOLD_ZOD_RANGE,
},
devDependencies: {
'@types/node': '^22.0.0',
typescript: '^5.8.0',
vitest: '^4.0.0',
'@types/node': SCAFFOLD_TYPES_NODE_RANGE,
typescript: SCAFFOLD_TYPESCRIPT_RANGE,
vitest: SCAFFOLD_VITEST_RANGE,
},
}),
'tsconfig.json': (name: string) =>
standalone
? {
compilerOptions: {
...STANDALONE_COMPILER_OPTIONS,
outDir: 'dist',
rootDir: 'src',
declaration: true,
},
include: ['src/**/*'],
exclude: ['dist', 'node_modules'],
}
? renderScaffoldTsconfig({
rootDir: 'src',
include: SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY,
})
: {
extends: rootTsconfigExtends(PLUGIN_IN_REPO_DIR, `plugin-${name}`),
compilerOptions: {
Expand Down Expand Up @@ -367,13 +359,13 @@ MIT
dependencies: {
'@objectstack/spec': objectstackDependencySpec(placement),
'@objectstack/cli': objectstackDependencySpec(placement),
zod: '^4.3.6',
zod: SCAFFOLD_ZOD_RANGE,
},
devDependencies: {
'@types/node': '^22.0.0',
tsx: '^4.21.0',
typescript: '^5.8.0',
vitest: '^4.0.0',
'@types/node': SCAFFOLD_TYPES_NODE_RANGE,
tsx: SCAFFOLD_TSX_RANGE,
typescript: SCAFFOLD_TYPESCRIPT_RANGE,
vitest: SCAFFOLD_VITEST_RANGE,
},
}),
'objectstack.config.ts': (name: string) => {
Expand Down Expand Up @@ -445,16 +437,10 @@ ${
}`,
'tsconfig.json': (name: string) =>
standalone
? {
compilerOptions: {
...STANDALONE_COMPILER_OPTIONS,
outDir: 'dist',
rootDir: '.',
declaration: true,
},
include: ['*.ts', 'src/**/*'],
exclude: ['dist', 'node_modules'],
}
? renderScaffoldTsconfig({
rootDir: '.',
include: SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG,
})
: {
extends: rootTsconfigExtends(EXAMPLE_IN_REPO_DIR, name),
compilerOptions: {
Expand Down
168 changes: 149 additions & 19 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,145 @@ export const SCAFFOLD_ALLOWED_PEER_VERSIONS: Record<string, string> = {
*/
export const SCAFFOLD_PNPM_RANGE = '>=10.15';

// ─── Shared emission policy ──────────────────────────────────────────
//
// The third-party ranges and the `tsconfig.json` an emitted project carries,
// declared ONCE for both scaffolders in this package.
//
// ## Why these are constants and not literals in each template
//
// Restating them is the defect. Measured on the tree, the TypeScript range a
// scaffolded project installs was written in SIX places across three
// scaffolders and had split into THREE values:
//
// os init `^5.3.0` (three write points in this file)
// os create `^5.8.0` (two write points in create.ts)
// create-objectstack `^6.0.0` (its bundled template's package.json)
//
// The split is not recent and nobody caused it deliberately. Read off `git
// log -G` over the three files, as of 2026-09-05:
//
// 338e68d2564 2026-02-07 `^5.3.0` and `^5.8.0` written in the SAME commit
// — 210 days apart and counting
// dbb54e12f0c 2026-05-25 the bundled template lands, also at `^5.3.0`
// — so this is still a TWO-value tree
// eaff01425b7 2026-07-14 that template's line moves `^5.3.0` → `^6.0.0`
// — the third value, 53 days old
//
// ⚠️ And the move that made the third value recorded no reasoning about
// TypeScript at all. #2907's commit message documents the `@objectstack/*`
// version sync and nothing else, and in that same one-file diff the five
// `@objectstack/*` ranges move `^6.0.0` → `^14.0.0` while the `typescript`
// line moves ONTO the `^6.0.0` those lines are vacating. Whatever the intent
// was, no statement of it exists — which is the point: an unrecorded value is
// what a restatement decays into, and it decayed on the value that decides
// whether a new project type-checks at all.
//
// The control for that reading is in this same file: `SCAFFOLD_PNPM_RANGE`
// and `renderPnpmWorkspaceYaml()` are IMPORTED by the other scaffolder rather
// than restated, and they have not drifted across any of the five emissions.
// Same files, same authors, same window — the restated values split, the
// imported ones did not. That is the whole argument for this block.
//
// ## The surviving values, and why they are these
//
// `^5.3.0` over `^5.8.0`: **`TypeScript 5.3+` is already a recorded decision**
// on two live doc pages — `content/docs/getting-started/index.mdx` ("ObjectStack
// works with TypeScript 5.3+, but the project itself is built and tested
// against TypeScript 6.x") and `content/docs/deployment/troubleshooting.mdx`
// ("TypeScript 5.3.0 or later for full type inference support"). `^5.8.0`
// matches no statement anywhere. It is also the value three of the five
// emissions already carried, and it is measured rather than assumed: TypeScript
// 5.3.3 type-checks every shape these two commands emit with results identical
// to 6.0.3 (measured against this repo's own `@objectstack/spec` build, on the
// `skipLibCheck` configuration the scaffold actually emits).
//
// `^4.0.0` over `^4.0.18`: no recorded decision exists for either, both were
// written in the same 2026-02-07 commit, and `^4.0.18` claims a PATCH-level
// floor nothing justifies while being strictly the narrower of the two.
//
// ⚠️ Neither choice changes what a scaffolded project INSTALLS. Measured
// against the registry: `^5.3.0` and `^5.8.0` both resolve to typescript
// 5.9.3, and `^4.0.0` and `^4.0.18` both resolve to vitest 4.1.11. What
// changes is the floor each project DECLARES — and a floor is a support
// promise, so the one that survives is the one the docs already make.
//
// ⛔ `create-objectstack`'s `^6.0.0` is deliberately NOT unified here. That
// package cannot import from `@objectstack/cli`: the dependency edge already
// runs the other way (`create-objectstack` is a `workspace:*` dependency of
// this package, and this file imports its `created-summary` renderer), so a
// reverse import is a cycle — and it publishes as a two-dependency `npx`
// package that must not pull the CLI's ~50-package closure. Its emission is
// also a committed template file copied byte-for-byte, with no renderer to
// route through a constant. Unifying it would move a scaffolded project from
// TypeScript 6.0.3 to 5.9.3, which is a user-visible change and a support
// decision, not a refactor.

/** The TypeScript range every scaffolded project declares. */
export const SCAFFOLD_TYPESCRIPT_RANGE = '^5.3.0';

/** The vitest range a scaffolded project declares when its template tests. */
export const SCAFFOLD_VITEST_RANGE = '^4.0.0';

/** The `@types/node` range a scaffolded project declares. */
export const SCAFFOLD_TYPES_NODE_RANGE = '^22.0.0';

/** The `tsx` range a scaffolded project declares when its scripts need it. */
export const SCAFFOLD_TSX_RANGE = '^4.21.0';

/** The zod range a scaffolded project declares when it authors schemas. */
export const SCAFFOLD_ZOD_RANGE = '^4.3.6';

/**
* The compiler options every STANDALONE scaffold carries in full, because it
* extends nothing.
*
* `bundler` is what resolves the `exports` subpaths (`@objectstack/spec/data`,
* `/contracts`, `/kernel`) the templates import; two scaffolders that disagree
* about `moduleResolution` is a support question nobody can answer. The
* `--in-repo` placement of `os create` does NOT use these — it inherits its
* module semantics from the repo config it extends.
*/
export const SCAFFOLD_TSCONFIG_COMPILER_OPTIONS = {
target: 'ES2022',
module: 'ESNext',
moduleResolution: 'bundler',
strict: true,
esModuleInterop: true,
skipLibCheck: true,
} as const;

/**
* Render the `tsconfig.json` a standalone scaffold receives.
*
* `rootDir` and `include` are the only things the emitted shapes differ on:
* `os create plugin` compiles `src/` alone, while `os init`'s three templates
* and `os create example` also compile the `objectstack.config.ts` at the
* project root. Measured before this renderer existed, four of the five
* emitted `tsconfig.json` files were already byte-identical and the fifth
* differed only in those two keys — so nothing here is a new decision.
*/
export function renderScaffoldTsconfig(
options: { rootDir: string; include: string[] },
): Record<string, unknown> {
return {
compilerOptions: {
...SCAFFOLD_TSCONFIG_COMPILER_OPTIONS,
outDir: 'dist',
rootDir: options.rootDir,
declaration: true,
},
include: options.include,
exclude: ['dist', 'node_modules'],
};
}

/** `include` for a scaffold whose root `objectstack.config.ts` is compiled too. */
export const SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG = ['*.ts', 'src/**/*'];

/** `include` for a scaffold that compiles `src/` alone. */
export const SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY = ['src/**/*'];

/**
* Render the `package.json` written into a freshly scaffolded project.
*
Expand Down Expand Up @@ -381,7 +520,7 @@ export const TEMPLATES: Record<string, {
get devDependencies() {
return {
'@objectstack/cli': pkgVersion(),
'typescript': '^5.3.0',
'typescript': SCAFFOLD_TYPESCRIPT_RANGE,
};
},
scripts: {
Expand Down Expand Up @@ -471,8 +610,8 @@ export default ${toCamelCase(namespace)}Item;
get devDependencies() {
return {
'@objectstack/cli': pkgVersion(),
'typescript': '^5.3.0',
'vitest': '^4.0.18',
'typescript': SCAFFOLD_TYPESCRIPT_RANGE,
'vitest': SCAFFOLD_VITEST_RANGE,
};
},
scripts: {
Expand Down Expand Up @@ -545,7 +684,7 @@ export default ${toCamelCase(namespace)}Item;
get devDependencies() {
return {
'@objectstack/cli': pkgVersion(),
'typescript': '^5.3.0',
'typescript': SCAFFOLD_TYPESCRIPT_RANGE,
};
},
scripts: {
Expand Down Expand Up @@ -856,21 +995,12 @@ export default class Init extends Command {
// 3. Create tsconfig.json if missing
const tsconfigPath = path.join(targetDir, 'tsconfig.json');
if (!fs.existsSync(tsconfigPath)) {
const tsconfig = {
compilerOptions: {
target: 'ES2022',
module: 'ESNext',
moduleResolution: 'bundler',
strict: true,
esModuleInterop: true,
skipLibCheck: true,
outDir: 'dist',
rootDir: '.',
declaration: true,
},
include: ['*.ts', 'src/**/*'],
exclude: ['dist', 'node_modules'],
};
// The shared emission policy, not a second copy of it — every option
// below used to be restated here and again in `create.ts`.
const tsconfig = renderScaffoldTsconfig({
rootDir: '.',
include: SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG,
});
fs.writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2) + '\n');
}

Expand Down
Loading
Loading