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
9 changes: 9 additions & 0 deletions .changeset/tidy-scaffolds-anchor-init.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@object-ui/cli': patch
---

`objectui init` now versions the project it scaffolds against the CLI that wrote it, and stops writing a `tailwind.config.js` Tailwind 4 never reads.

The generated `package.json` asked for `@object-ui/components` and `@object-ui/react` at `^2.0.0` while those packages publish at 17.x, so `npm install` in a fresh scaffold resolved a major unrelated to the CLI that produced it. Both ranges are now derived from the CLI's own version, which is sound because `.changeset/config.json` releases the CLI and every platform package from one `fixed` group. The scaffold's toolchain ranges had drifted the same way — vite `^7.3.1` against the repo's `^8.2.0`, typescript `^5.9.3` against `^6.0.3`, and seven more — and now read from the same table the temp-app generators use rather than from literals of their own.

The scaffold's CSS pipeline was already Tailwind 4 (`@tailwindcss/postcss`, `@import 'tailwindcss'`), and v4 reads a JS config only when a stylesheet points `@config` at one. The `tailwind.config.js` written beside it was therefore inert — an authoritative-looking `content` list nothing consumed — and is no longer written.
125 changes: 99 additions & 26 deletions packages/cli/src/__tests__/app-generator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,7 @@ import { fileURLToPath } from 'node:url';

import { describe, expect, it } from 'vitest';

import { buildInitPackageJson } from '../commands/init.js';
import {
buildAppFiles,
buildAppPackageJson,
Expand DownExpand Up@@ -270,14 +271,25 @@ function inRepoRangesOf(name: string): Record<string, string[]> {
}

/**
* Where each range in the two generated manifests must come from.
* Where each range in the THREE generated manifests must come from.
*
* The anchoring discipline objectui#3742/objectui#3754 established: one range
* per dependency in this repo, quoted rather than invented, so bumping an
* in-repo manifest and leaving a generator behind fails a test instead of
* shipping. These literals live in `.ts` source, outside the objectui#3711
* version-claims gate's scan face, so this map is the only gate they have.
*
* The third manifest is `commands/init.ts`'s (`buildInitPackageJson`), folded
* in by objectui#3892. It is the one an EXTERNAL user gets — `objectui init` is
* the first command they run — and it sat outside this table until then, which
* is how it came to ask for `@object-ui/*` at `^2.0.0` against packages
* publishing at 17.x while its toolchain ranges drifted a major behind the repo
* (vite `^7.3.1`, typescript `^5.9.3`). Its key set is a strict subset of this
* table's: it declares neither the seven plugins nor `lucide-react` /
* `react-router-dom`, because its generated sources import none of them — so
* the completeness check below unions the three and the per-range check finds
* each name in whichever manifest declares it.
*
* - `root` — the repo root declares it; the generated range must match.
* - `in-repo` — the root does not, but sibling manifests do, unanimously.
* - `cli-version` — derived from this CLI's own version at generation time, not
Expand DownExpand Up@@ -447,46 +459,107 @@ describe('generated app manifests', () => {
expect(unusedVersionedDependencies(withUnused, plainFiles())).toEqual(['lucide-react']);
});

it('keeps both generated dependency maps under one anchor table', () => {
// Completeness: a range added to either generator without naming its anchor
// fails here, which is what kept the eight fossils invisible before.
it('keeps all three generated dependency maps under one anchor table', () => {
// Completeness: a range added to any generator without naming its anchor
// fails here, which is what kept the eight fossils invisible before — and,
// until the init manifest joined the union (objectui#3892), what let a whole
// third generator fossilise without ever failing anything.
const declared = new Set([
...Object.keys(allRangesOf(buildAppPackageJson(STANDALONE))),
...Object.keys(allRangesOf(buildRoutedAppPackageJson()))
...Object.keys(allRangesOf(buildRoutedAppPackageJson())),
...Object.keys(allRangesOf(buildInitPackageJson('sample-app')))
]);
expect([...declared].sort()).toEqual(Object.keys(DEPENDENCY_ANCHORS).sort());
});

it('sources every range from this repo instead of inventing one', () => {
const routed = allRangesOf(buildRoutedAppPackageJson());
const plain = allRangesOf(buildAppPackageJson(STANDALONE));
const init = allRangesOf(buildInitPackageJson('sample-app'));
const cliVersion = readManifest(CLI_MANIFEST_PATH).version as string;

for (const [name, anchor] of Object.entries(DEPENDENCY_ANCHORS)) {
const generated = routed[name] ?? plain[name];
expect(generated, `${name} must be declared by at least one generator`).toBeTruthy();

if (anchor === 'cli-version') {
expect(generated, `${name} must track this CLI's own version`).toBe(`^${cliVersion}`);
continue;
// Every manifest that declares the name is judged, not just the first —
// one generator anchored and another fossilised is exactly the state
// objectui#3892 found, and reading `routed ?? plain ?? init` would have
// reported it green.
const declaredBy = { routed: routed[name], plain: plain[name], init: init[name] };
expect(
Object.values(declaredBy).some((range) => range !== undefined),
`${name} must be declared by at least one generator`
).toBe(true);

for (const [generator, generated] of Object.entries(declaredBy)) {
if (generated === undefined) continue;
const where = `${generator} manifest's ${name}`;

if (anchor === 'cli-version') {
expect(generated, `${where} must track this CLI's own version`).toBe(`^${cliVersion}`);
continue;
}

if (anchor === 'root') {
const rootRange = rootRangeOf(name);
expect(rootRange, `${name} must exist in the root manifest`).toBeTruthy();
expect(generated, `${where} must match the repo root`).toBe(rootRange);
continue;
}

const byRange = inRepoRangesOf(name);
const ranges = Object.keys(byRange);
expect(ranges.length, `${name} must be declared in-repo to anchor to`).toBeGreaterThan(0);
expect(
ranges.sort(),
`in-repo manifests disagree on ${name}: ${JSON.stringify(byRange)} — settle on one range first`
).toHaveLength(1);
expect(generated, `${where} must match its in-repo range`).toBe(ranges[0]);
}
}
});

if (anchor === 'root') {
const rootRange = rootRangeOf(name);
expect(rootRange, `${name} must exist in the root manifest`).toBeTruthy();
expect(generated, `${name} must match the repo root`).toBe(rootRange);
continue;
}
it('names every range the pre-fix init manifest had drifted on', () => {
// The reverse verification for objectui#3892, direction predicted before
// running: feed the anchor rule the literal map `commands/init.ts` shipped
// and every one of its 13 ranges must be judged WRONG — the two
// `@object-ui/*` at `^2.0.0` against a CLI at 17.x (the reported defect),
// and the eleven toolchain fossils the measurement turned up beside it.
// Thirteen, not two, is the size of the drift.
//
// The direction is plain red rather than the inverted shape objectui#5009
// hit, because this rule compares a generated string against a repo fact:
// there is no schema underneath it to re-judge the same input differently.
const preFix: Record<string, string> = {
'@object-ui/components': '^2.0.0',
'@object-ui/react': '^2.0.0',
react: '^19.2.0',
'react-dom': '^19.2.0',
'@tailwindcss/postcss': '^4.1.18',
'@types/react': '^19.2.13',
'@types/react-dom': '^19.2.6',
'@vitejs/plugin-react': '^5.1.3',
autoprefixer: '^10.4.23',
postcss: '^8.5.6',
tailwindcss: '^4.1.18',
typescript: '^5.9.3',
vite: '^7.3.1'
};
const cliVersion = readManifest(CLI_MANIFEST_PATH).version as string;

const byRange = inRepoRangesOf(name);
const ranges = Object.keys(byRange);
expect(ranges.length, `${name} must be declared in-repo to anchor to`).toBeGreaterThan(0);
expect(
ranges.sort(),
`in-repo manifests disagree on ${name}: ${JSON.stringify(byRange)} — settle on one range first`
).toHaveLength(1);
expect(generated, `${name} must match its in-repo range`).toBe(ranges[0]);
}
const drifted = Object.entries(preFix)
.filter(([name, range]) => {
const anchor = DEPENDENCY_ANCHORS[name];
if (anchor === 'cli-version') return range !== `^${cliVersion}`;
if (anchor === 'root') return range !== rootRangeOf(name);
return range !== Object.keys(inRepoRangesOf(name))[0];
})
.map(([name]) => name)
.sort();

expect(drifted).toEqual(Object.keys(preFix).sort());
// And the manifest shipping today is the exact complement: nothing drifted.
expect(Object.keys(allRangesOf(buildInitPackageJson('sample-app'))).sort()).toEqual(
Object.keys(preFix).sort()
);
});

it('keeps the root and in-repo anchors consistent wherever both declare one', () => {
Expand Down
55 changes: 52 additions & 3 deletions packages/cli/src/__tests__/cli-bin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -196,14 +196,18 @@ describe('@object-ui/cli bin', () => {

describe('init', () => {
let work: string;
let appDir: string;
// Scaffolded once in `beforeAll` rather than by the first `it`, so the
// assertions below do not silently depend on test ordering (objectui#3892
// added three of them to what was a single self-contained case).
beforeAll(() => {
work = mkdtempSync(join(tmpdir(), 'objectui-cli-init-'));
appDir = join(work, 'sample-app');
const res = run(['init', 'sample-app', '-t', 'simple'], { cwd: work });
expect(res.code, res.stdout + res.stderr).toBe(0);
});

it('scaffolds a project with the simple template', () => {
const res = run(['init', 'sample-app', '-t', 'simple'], { cwd: work });
expect(res.code, res.stdout + res.stderr).toBe(0);
const appDir = join(work, 'sample-app');
for (const f of [
'app.json',
'package.json',
Expand All@@ -219,5 +223,50 @@ describe('@object-ui/cli bin', () => {
const schema = JSON.parse(readFileSync(join(appDir, 'app.json'), 'utf-8'));
expect(schema).toHaveProperty('type');
});

it('writes no tailwind.config.js, because v4 would never read it', () => {
// objectui#3892, mirroring what objectui#3852 did for the temp-app
// generators. The scaffold's pipeline is Tailwind 4 end to end
// (`@tailwindcss/postcss` in `postcss.config.js`, `@import 'tailwindcss'`
// in `src/index.css`), and v4 reads a JS config only when a stylesheet
// points `@config` at it. The file this used to write was inert: an
// authoritative-looking `content` list nothing consumed.
//
// Asserted through the real bin rather than over a file map, because
// `init()` writes with `fs` directly — an absence is only meaningful
// where the writes actually happen.
expect(existsSync(join(appDir, 'tailwind.config.js'))).toBe(false);
expect(existsSync(join(appDir, 'tailwind.config.ts'))).toBe(false);
expect(readFileSync(join(appDir, 'src/index.css'), 'utf-8')).not.toContain('@config');
// The v4 pipeline it is inert *relative to*, so the absence above cannot
// be read as "this scaffold is not on Tailwind at all".
expect(readFileSync(join(appDir, 'postcss.config.js'), 'utf-8')).toContain(
`'@tailwindcss/postcss': {}`
);
expect(readFileSync(join(appDir, 'src/index.css'), 'utf-8')).toContain(
`@import 'tailwindcss';`
);
});

it('versions the scaffold against the CLI that wrote it, not a literal', () => {
// objectui#3892's reported defect, gated where the user meets it: the
// manifest asked for `@object-ui/*` at `^2.0.0` while the CLI and every
// platform package publish at 17.x from one `fixed` changeset group, so
// `npm install` in a fresh scaffold resolved a major unrelated to the CLI
// that produced it.
//
// `app-generator.test.ts` anchors `buildInitPackageJson`'s ranges; this
// asserts `init()` actually WRITES that manifest, which is the half a
// unit test on the builder cannot see.
const cliVersion = JSON.parse(readFileSync(PKG_PATH, 'utf-8')).version as string;
const manifest = JSON.parse(readFileSync(join(appDir, 'package.json'), 'utf-8')) as { dependencies: Record<string, string>; devDependencies: Record<string, string> };

expect(manifest.dependencies['@object-ui/react']).toBe(`^${cliVersion}`);
expect(manifest.dependencies['@object-ui/components']).toBe(`^${cliVersion}`);
expect(manifest.dependencies['@object-ui/react']).not.toBe('^2.0.0');
// A spot check that the toolchain half is written too — the full anchor
// judgement lives in `app-generator.test.ts`.
expect(manifest.devDependencies.vite).not.toBe('^7.3.1');
});
});
});
90 changes: 51 additions & 39 deletions packages/cli/src/commands/init.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,12 @@ import { existsSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import chalk from 'chalk';

import {
platformPackageRange,
REACT_RANGE,
SCAFFOLD_DEV_DEPENDENCIES
} from '../utils/scaffold-dependencies.js';

interface InitOptions {
template: string;
}
Expand DownExpand Up@@ -357,6 +363,42 @@ const templates = {
},
};

/**
* The `package.json` `objectui init` writes into a new user project.
*
* Split out of `init()` so it can be judged without running the command:
* `app-generator.test.ts`'s `DEPENDENCY_ANCHORS` now covers this manifest
* alongside the two temp-app generators, which is the gate it had none of
* before (objectui#3892 — the literals live in `.ts` source, so objectui#3711's
* version-claims scan never saw them either).
*
* Not one range is written here. Every value comes from
* `utils/scaffold-dependencies.ts`, which is also what the app generators read;
* see that module for why the `@object-ui/*` range is derived from the CLI's
* own version rather than declared.
*/
export function buildInitPackageJson(name: string): Record<string, unknown> {
const platformRange = platformPackageRange();
return {
name,
version: '0.1.0',
private: true,
type: 'module',
scripts: {
dev: 'vite',
build: 'tsc && vite build',
preview: 'vite preview',
},
dependencies: {
'@object-ui/components': platformRange,
'@object-ui/react': platformRange,
react: REACT_RANGE,
'react-dom': REACT_RANGE,
},
devDependencies: { ...SCAFFOLD_DEV_DEPENDENCIES },
};
}

export async function init(name: string, options: InitOptions) {
const cwd = process.cwd();
const projectDir = join(cwd, name);
Expand DownExpand Up@@ -450,36 +492,7 @@ dist
console.log(chalk.green('✓ Created .gitignore'));

// Create package.json
const packageJson = {
name,
version: '0.1.0',
private: true,
type: 'module',
scripts: {
dev: 'vite',
build: 'tsc && vite build',
preview: 'vite preview',
},
dependencies: {
'@object-ui/components': '^2.0.0',
'@object-ui/react': '^2.0.0',
react: '^19.2.0',
'react-dom': '^19.2.0',
},
devDependencies: {
'@tailwindcss/postcss': '^4.1.18',
'@types/react': '^19.2.13',
'@types/react-dom': '^19.2.6',
'@vitejs/plugin-react': '^5.1.3',
autoprefixer: '^10.4.23',
postcss: '^8.5.6',
tailwindcss: '^4.1.18',
typescript: '^5.9.3',
vite: '^7.3.1',
},
};

writeFileSync(join(targetDir, 'package.json'), JSON.stringify(packageJson, null, 2));
writeFileSync(join(targetDir, 'package.json'), JSON.stringify(buildInitPackageJson(name), null, 2));
console.log(chalk.green('✓ Created package.json'));

// Create vite.config.ts
Expand All@@ -497,15 +510,14 @@ export default defineConfig({
writeFileSync(join(targetDir, 'vite.config.ts'), viteConfig);
console.log(chalk.green('✓ Created vite.config.ts'));

// Create tailwind.config.js
const tailwindConfig = `/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
};
`;

writeFileSync(join(targetDir, 'tailwind.config.js'), tailwindConfig);
console.log(chalk.green('✓ Created tailwind.config.js'));
// No tailwind.config.js is written: this scaffold's CSS pipeline is Tailwind 4
// (`postcss.config.js` names `@tailwindcss/postcss`, `src/index.css` does
// `@import 'tailwindcss'`), and v4 reads a JS config only when a stylesheet
// points `@config` at one — none does. The file this used to write was
// therefore inert: an authoritative-looking `content` list that nothing
// consumed, while v4's own source detection already covers `index.html` and
// `src/**` from the project root. objectui#3852 removed the same dead file
// from the temp-app generators; objectui#3892 removes it here.

// Create postcss.config.js
const postcssConfig = `export default {
Expand Down
Loading
Loading