Skip to content

Commit 8d21f7a

Browse files
Elon Muskclaude
andauthored
fix(create-objectstack): scaffolder "Next steps" names the pm it actually used (#11013)
* fix(create-objectstack): scaffolder Next steps names the pm it actually used npm vs pnpm vs `npm run`: a newcomer got three different answers to "what do I run next" — the scaffolder's own printed "Next steps" hardcoded `npm run dev` / `npm run validate` regardless of which package manager the run actually installed with, while the generated blank/README.md consistently said pnpm. Read at HEAD: `detectPackageManager()` (packages/create-objectstack/src/index.ts) still prefers pnpm and falls back to npm only when pnpm is unreachable — confirmed still the tool's real behaviour, matching triage's evidence. Also measured empirically with the built CLI: a real run with pnpm on PATH installs with pnpm (pnpm-lock.yaml, "Done in ... using pnpm vX") and then printed `npm run dev` / `npm run validate` as next steps — the exact three-answers defect. A second real run with pnpm made unreachable (PATH without it) correctly fell back to npm and produced package-lock.json, confirming the fallback is real and must not be stranded by deleting the npm path outright. Fix: detect the package manager once, up front (a read-only `<pm> --version` probe, so it costs nothing even under --skip-install), and reuse that one value for the install command, the install-failure remedy, and every line of "Next steps" — so the printed guidance always names the tool the run actually used, in both the pnpm and the npm-fallback case. Pinned end-to-end via tsx (scaffold-next-steps-pm.test.ts), exercising both branches by controlling PATH. Also names `validate` — the step the generated AGENTS.md calls unskippable — in the blank template's "Getting started" section, not only in its later "Verify your changes" section, so a newcomer reading top-to-bottom sees it at first touch. The README is a static template file (only its H1 is rewritten at scaffold time), so this is pinned as a source-text assertion (blank-readme-validate-disclosure.test.ts) rather than a runtime test. Root README.md's `npm create objectstack@latest` (cluster A) and the scaffolder's `◆ Create ObjectStack v6.x` banner (#10325, shared index.ts, still open) are deliberately untouched — out of this card's scope. Fixes #10322 Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r * fix(create-objectstack): declare init.ts's mention in the cross-package roster The Lint & Repo Gates check:cross-package-test-inputs failure on #10322: scaffold-next-steps-pm.test.ts's header comment cites packages/cli/src/commands/init.ts in backticks (contrasting create-objectstack's new detected-package-manager guidance with init.ts's own, already-threaded "Next steps"), and the flat literal collector takes quoted repo-relative paths without parsing comments. Per the roster's own settled precedent (serve.ts, gen-sdui-manifest.sh, publish-smoke.sh — mentions rather than reads), the fix is to declare the file, not reword the comment. Mirrors the entry into turbo.json's create-objectstack#test inputs. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f334d66 commit 8d21f7a

7 files changed

Lines changed: 261 additions & 17 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"create-objectstack": patch
3+
---
4+
5+
Fix `create-objectstack`'s closing "Next steps" and install-failure remedy
6+
hardcoding `npm` regardless of which package manager the run actually used
7+
(#10322). `detectPackageManager()` already prefers `pnpm` and falls back to
8+
`npm` only when `pnpm` is unreachable — confirmed still true at HEAD, and
9+
confirmed empirically: a real run with `pnpm` on `PATH` installs with `pnpm`
10+
(`pnpm-lock.yaml`, "Done in … using pnpm vX") and then told the newcomer to
11+
run `npm run dev` / `npm run validate` next, a package manager the run never
12+
touched. The detected package manager is now read once, up front, and reused
13+
consistently for the install command, the install-failure remedy, and every
14+
line of "Next steps" — so the printed guidance always names the tool the run
15+
actually used, in both the `pnpm` and the `npm`-fallback case.
16+
17+
Also names `validate` — the step the generated `AGENTS.md` calls
18+
unskippable — in the "Getting started" section of the generated `blank`
19+
template's README, not only in its later "Verify your changes" section, so a
20+
newcomer reading top-to-bottom sees it at first touch.
21+
22+
No install behaviour changes: the scaffolder still installs by default and
23+
still supports `--skip-install`; this is a messaging-only fix.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// Pins #10322 part 3 — the substantive half, per triage: the generated
4+
// `AGENTS.md` calls `validate` the command you must never skip ("Never report
5+
// a metadata change as done until `npm run validate` passes"), and the
6+
// newcomer's primary doc, the blank template's own README, must name it where
7+
// a newcomer reading top-to-bottom actually sees it, not only in a section
8+
// further down the file. This template is a STATIC file — `index.ts` copies
9+
// it byte-for-byte (only the first H1 line is rewritten, by
10+
// `rewriteProjectIdentity`) — so there is no code path to unit-test; this
11+
// source-text pin is what covers it. A fuller explanation of *why* to run it
12+
// already lives in the "## Verify your changes" section further down; this
13+
// pin is deliberately about the FIRST section a newcomer reads, not a
14+
// duplicate of that explanation.
15+
16+
import { describe, it, expect } from 'vitest';
17+
import fs from 'node:fs';
18+
import path from 'node:path';
19+
import { fileURLToPath } from 'node:url';
20+
21+
const HERE = path.dirname(fileURLToPath(import.meta.url));
22+
const blankRoot = path.resolve(HERE, 'templates', 'blank');
23+
const readme = fs.readFileSync(path.join(blankRoot, 'README.md'), 'utf8');
24+
25+
describe('blank template README names `validate` at first touch (#10322)', () => {
26+
it('reads the real template README (vacuity guard)', () => {
27+
expect(readme).toMatch(/^## Getting started$/m);
28+
});
29+
30+
it('mentions `validate` in or immediately after "Getting started" — not only further down', () => {
31+
// Everything from the "Getting started" heading up to (not including) the
32+
// next `## ` heading after it, minus the heading's own code fence — this
33+
// is what a newcomer reads before scrolling past the first section.
34+
const gettingStarted = readme.split(/^## Getting started$/m)[1]?.split(/^## /m)[0] ?? '';
35+
expect(
36+
gettingStarted,
37+
'"Getting started" must mention `validate` — otherwise a newcomer who ' +
38+
'only reads the first section never learns about the command ' +
39+
"AGENTS.md calls unskippable.",
40+
).toMatch(/\bvalidate\b/);
41+
});
42+
43+
it('the validate step named at first touch matches the fuller explanation below', () => {
44+
expect(readme).toMatch(/^## Verify your changes$/m);
45+
const verifySection = readme.split(/^## Verify your changes$/m)[1]?.split(/^## /m)[0] ?? '';
46+
expect(verifySection).toMatch(/\bvalidate\b/);
47+
});
48+
49+
it('names one consistent package manager throughout — no bare npm mixed into a pnpm doc', () => {
50+
// #10322 part 1: pick one and say it everywhere. The blank template
51+
// already used pnpm consistently; this pin keeps it that way. Excludes
52+
// the `engines.pnpm` prose about pnpm-version floors living in
53+
// template-consistency.test.ts, and non-pm words like "npm" never occur
54+
// here at all today — so a plain absence check is the right shape.
55+
expect(readme).not.toMatch(/\bnpm run\b/);
56+
expect(readme).not.toMatch(/\bnpm install\b/);
57+
});
58+
});

packages/create-objectstack/src/index.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,17 @@ const program = new Command()
429429
const targetDir = name ? path.resolve(cwd, name) : cwd;
430430
const isCurrentDir = targetDir === cwd;
431431

432+
// Detected once, up front, and reused for every command this run prints or
433+
// runs — the install line, the failure remedy and the closing "Next
434+
// steps" all name the SAME package manager. Detecting it here (rather than
435+
// only inside the install branch) means `--skip-install` still gets an
436+
// accurate "Next steps" instead of a guess: the probe is a read-only
437+
// `<pm> --version` check, so running it costs nothing even when there is
438+
// no install to drive. Previously "Next steps" hardcoded `npm` regardless
439+
// of which package manager actually ran (#10322) — a newcomer who just
440+
// watched `pnpm install` run was then told `npm run dev`.
441+
const pm = detectPackageManager();
442+
432443
printKV('Environment', projectName);
433444
printKV('Namespace', namespace);
434445
printKV('Template', `${options.template}${template.description}`);
@@ -469,12 +480,11 @@ const program = new Command()
469480
printStep('Installing dependencies...');
470481
let installed = false;
471482
try {
472-
const pm = detectPackageManager();
473483
execSync(`${pm} install`, { stdio: 'inherit', cwd: targetDir });
474484
installed = true;
475485
console.log('');
476486
} catch {
477-
printWarning('Dependency installation failed. Run `npm install` manually.');
487+
printWarning(`Dependency installation failed. Run \`${pm} install\` manually.`);
478488
console.log('');
479489
}
480490

@@ -539,11 +549,18 @@ const program = new Command()
539549
console.log(chalk.dim(` cd ${name}`));
540550
}
541551
if (options.skipInstall) {
542-
console.log(chalk.dim(' npm install'));
552+
console.log(chalk.dim(` ${pm} install`));
543553
}
544-
console.log(chalk.dim(' npm run dev # Start development server'));
545-
console.log(chalk.dim(' npm run validate # Verify metadata: schema + predicates + bindings'));
546-
console.log(chalk.dim(' # (run after every metadata edit — see AGENTS.md)'));
554+
// Same `${pm} run …` shape for both commands, padded to the longer of
555+
// the two labels so the trailing comments still line up — for either
556+
// package manager name, not just the `npm`-length one the literal
557+
// strings above were hand-kerned for.
558+
const devLabel = `${pm} run dev`;
559+
const validateLabel = `${pm} run validate`;
560+
const labelWidth = Math.max(devLabel.length, validateLabel.length) + 3;
561+
console.log(chalk.dim(` ${devLabel.padEnd(labelWidth)}# Start development server`));
562+
console.log(chalk.dim(` ${validateLabel.padEnd(labelWidth)}# Verify metadata: schema + predicates + bindings`));
563+
console.log(chalk.dim(` ${' '.repeat(labelWidth)}# (run after every metadata edit — see AGENTS.md)`));
547564
if (options.skipInstall || options.skipSkills) {
548565
console.log('');
549566
console.log(chalk.bold(' AI Skills (recommended):'));
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// Pins #10322: the printed "Next steps" (and the install-failure remedy) must
4+
// name the SAME package manager the run actually detected — never a
5+
// hardcoded `npm` regardless of what ran. Before this fix, a newcomer whose
6+
// install ran with `pnpm` (confirmed empirically: this scaffolder prefers
7+
// pnpm and only falls back to npm when pnpm is unreachable — see
8+
// `detectPackageManager()`) was told to run `npm run dev` / `npm run
9+
// validate` afterwards — the third of the "three different answers" #10322
10+
// measured. `packages/cli/src/commands/init.ts`'s own "Next steps" already
11+
// threads its detected `chosenPm` through; this file is the same contract for
12+
// `create-objectstack`.
13+
//
14+
// `index.ts` calls `program.parse()` at import time, so it cannot be
15+
// unit-tested directly — this exercises the real CLI end to end via `tsx`,
16+
// the same no-build subprocess pattern `scaffold-description.test.ts` uses.
17+
// `--skip-install` keeps every run here fast and offline: `detectPackageManager()`
18+
// is a read-only `<pm> --version` probe (see index.ts), so its result — and
19+
// therefore what "Next steps" prints — does not depend on an install actually
20+
// following it. The *real* install path (both the pnpm and npm-fallback
21+
// cases) was additionally verified by hand against the built CLI; see this
22+
// issue's PR body for the transcripts.
23+
//
24+
// Both branches of the detector are exercised by controlling PATH:
25+
// - pnpm reachable -> "pnpm run dev" / "pnpm run validate"
26+
// - pnpm unreachable -> "npm run dev" / "npm run validate" (the fallback
27+
// this scaffolder has always had for machines without pnpm)
28+
//
29+
// The "no bare npm when pnpm ran" assertion is deliberately a WORD-BOUNDARY
30+
// match, not a substring one: the literal text "pnpm run" itself contains the
31+
// substring "npm run" (p-N-P-M-space-r-u-n has "npm run" starting at its
32+
// second character), so a naive `.not.toContain('npm run')` would fail
33+
// against correct pnpm output.
34+
35+
import { describe, it, expect } from 'vitest';
36+
import { execFileSync } from 'node:child_process';
37+
import fs from 'node:fs';
38+
import os from 'node:os';
39+
import path from 'node:path';
40+
import { fileURLToPath } from 'node:url';
41+
42+
const HERE = path.dirname(fileURLToPath(import.meta.url));
43+
const PKG_ROOT = path.resolve(HERE, '..');
44+
const REPO_ROOT = path.resolve(PKG_ROOT, '..', '..');
45+
const TSX = path.join(REPO_ROOT, 'node_modules', '.bin', 'tsx');
46+
const INDEX_TS = path.join(PKG_ROOT, 'src', 'index.ts');
47+
48+
function which(cmd: string): string {
49+
return execFileSync('sh', ['-c', `command -v ${cmd}`], { encoding: 'utf8' }).trim();
50+
}
51+
52+
/** A PATH entry with `node` + `npm` reachable and `pnpm` deliberately absent. */
53+
function makePnpmlessBin(): string {
54+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'create-objectstack-nopnpm-bin-'));
55+
fs.symlinkSync(which('node'), path.join(dir, 'node'));
56+
fs.symlinkSync(which('npm'), path.join(dir, 'npm'));
57+
return dir;
58+
}
59+
60+
/**
61+
* The "Next steps:" block of a run's stdout — deliberately narrower than the
62+
* whole transcript. The "Created files" listing above it names
63+
* `pnpm-workspace.yaml` regardless of which package manager ran the install
64+
* (it is a static template file, not install output), so a bare
65+
* `stdout.not.toMatch(/pnpm/)` would false-positive on that filename in the
66+
* npm-fallback case. What actually matters is what the run tells the reader
67+
* to type next.
68+
*/
69+
function nextStepsSection(stdout: string): string {
70+
return stdout.split('Next steps:')[1] ?? '';
71+
}
72+
73+
/** Run the real CLI with --skip-install --skip-skills and return its stdout. */
74+
function runScaffold(env: NodeJS.ProcessEnv): string {
75+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'create-objectstack-nextsteps-'));
76+
try {
77+
return execFileSync(
78+
TSX,
79+
[INDEX_TS, 'my-app', '--template', 'blank', '--skip-install', '--skip-skills'],
80+
{ cwd: tmp, env, encoding: 'utf8' },
81+
);
82+
} finally {
83+
fs.rmSync(tmp, { recursive: true, force: true });
84+
}
85+
}
86+
87+
describe('scaffolder "Next steps" names the package manager it actually detected (#10322)', () => {
88+
it('with pnpm on PATH: prints pnpm consistently, never bare npm', () => {
89+
// Sanity: this container really does have pnpm reachable, or the
90+
// "consistently pnpm" assertion below would be vacuous.
91+
expect(() => which('pnpm')).not.toThrow();
92+
93+
const nextSteps = nextStepsSection(runScaffold(process.env));
94+
expect(nextSteps).toMatch(/\bpnpm run dev\b/);
95+
expect(nextSteps).toMatch(/\bpnpm run validate\b/);
96+
expect(nextSteps).not.toMatch(/\bnpm run\b/);
97+
expect(nextSteps).not.toMatch(/\bnpm install\b/);
98+
}, 20_000);
99+
100+
it('with pnpm unreachable: falls back to npm — consistently, not a stale pnpm mention', () => {
101+
const bin = makePnpmlessBin();
102+
try {
103+
// Sanity: the fake PATH really does hide pnpm (and really does still
104+
// expose node/npm — otherwise tsx itself could not launch).
105+
expect(() =>
106+
execFileSync('sh', ['-c', 'command -v pnpm'], {
107+
env: { ...process.env, PATH: bin },
108+
}),
109+
).toThrow();
110+
111+
const nextSteps = nextStepsSection(
112+
runScaffold({ ...process.env, PATH: `${bin}:/usr/bin:/bin` }),
113+
);
114+
expect(nextSteps).toMatch(/\bnpm run dev\b/);
115+
expect(nextSteps).toMatch(/\bnpm run validate\b/);
116+
expect(nextSteps).not.toMatch(/pnpm/);
117+
} finally {
118+
fs.rmSync(bin, { recursive: true, force: true });
119+
}
120+
}, 20_000);
121+
122+
it('both branches still name the unskippable validate step (#10322 pt. 3)', () => {
123+
expect(runScaffold(process.env)).toMatch(/run validate/);
124+
}, 20_000);
125+
126+
it('the install-failure remedy also names the detected package manager, not a hardcoded npm', () => {
127+
const source = fs.readFileSync(INDEX_TS, 'utf8');
128+
expect(source).toMatch(/Dependency installation failed\. Run .*\$\{pm\} install.* manually\./);
129+
expect(source).not.toMatch(/Run `npm install` manually/);
130+
});
131+
});

packages/create-objectstack/src/templates/blank/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ pnpm install
99
pnpm dev
1010
```
1111

12+
After editing any metadata (an object, view, flow, …), run `pnpm validate`
13+
see [Verify your changes](#verify-your-changes) below. It is the one command
14+
this project's `AGENTS.md` calls unskippable: it catches mistakes that
15+
otherwise fail silently at runtime.
16+
1217
The REST API is served at `http://localhost:3000/api/v1`. Data endpoints
1318
require a session — the dev server seeds a login-ready admin
1419
(`admin@objectos.ai` / `admin123`) on an empty database:

scripts/check-cross-package-test-inputs.mjs

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -643,16 +643,24 @@ export const CROSS_PACKAGE_TEST_INPUTS = {
643643
// (its `paths:` filter is `packages/create-objectstack/**`), which is why
644644
// the test lives here rather than beside a shell script in spec (#9779).
645645
//
646-
// The last three are NAMED in that test's header rather than read, the same
647-
// shape as `check-nul-bytes.mjs` above and settled the same way: the literal
648-
// collector takes quoted paths without parsing, so a mention forces a
649-
// declaration, and declaring three rarely-touched files is cheaper than
650-
// rewording prose to dodge a scanner. `serve.ts` earns it on the merits too
651-
// — its `flags.dev || NODE_ENV === 'development'` port-shift gate is the
652-
// single fact that decides which fix those workflow blocks need, so a change
653-
// to that branch is exactly the change the test's premise would need
654-
// re-measuring against. The two sibling scripts are cited for the contrast
655-
// that keeps the fixes from being copied between them.
646+
// Three of the remaining four are NAMED in a test's header rather than
647+
// read, the same shape as `check-nul-bytes.mjs` above and settled the
648+
// same way: the literal collector takes quoted paths without parsing, so
649+
// a mention forces a declaration, and declaring a rarely-touched file is
650+
// cheaper than rewording prose to dodge a scanner. `serve.ts` earns it on
651+
// the merits too — its `flags.dev || NODE_ENV === 'development'`
652+
// port-shift gate is the single fact that decides which fix those
653+
// workflow blocks need, so a change to that branch is exactly the change
654+
// the test's premise would need re-measuring against. The two sibling
655+
// scripts are cited for the contrast that keeps the fixes from being
656+
// copied between them.
657+
//
658+
// `packages/cli/src/commands/init.ts` is the fourth of that shape (#10322):
659+
// scaffold-next-steps-pm.test.ts's header quotes it in backticks while
660+
// explaining that `init.ts`'s own "Next steps" output already threads its
661+
// detected `chosenPm` the same way this package's scaffolder now does —
662+
// it is cited for the contrast, never read. The test execs
663+
// `create-objectstack`'s own CLI via `tsx`, not `init.ts`.
656664
globs: [
657665
'content/**',
658666
'scripts/sync-template-versions.mjs',
@@ -670,6 +678,7 @@ export const CROSS_PACKAGE_TEST_INPUTS = {
670678
'packages/cli/src/commands/serve.ts',
671679
'scripts/gen-sdui-manifest.sh',
672680
'scripts/publish-smoke.sh',
681+
'packages/cli/src/commands/init.ts',
673682
],
674683
heldBy: {
675684
// Read through `git grep -- content/docs` and `git ls-files`, so the

turbo.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,8 @@
231231
"$TURBO_ROOT$/.github/workflows/scaffold-e2e.yml",
232232
"$TURBO_ROOT$/packages/cli/src/commands/serve.ts",
233233
"$TURBO_ROOT$/scripts/gen-sdui-manifest.sh",
234-
"$TURBO_ROOT$/scripts/publish-smoke.sh"
234+
"$TURBO_ROOT$/scripts/publish-smoke.sh",
235+
"$TURBO_ROOT$/packages/cli/src/commands/init.ts"
235236
]
236237
},
237238
"test:e2e": {

0 commit comments

Comments
 (0)