Skip to content

Commit c5a2785

Browse files
feat(create): Support for bun, pnpm, yarn when creating new project (#4877)
Co-authored-by: Michael Bromley <michael@michaelbromley.co.uk>
1 parent d753b56 commit c5a2785

13 files changed

Lines changed: 658 additions & 101 deletions

.github/workflows/publish_and_install.yml

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,91 @@ jobs:
209209
name: dashboard-test-screenshots-${{ matrix.os }}-${{ matrix.node-version }}
210210
path: /tmp/dashboard-test-*.png
211211
retention-days: 28
212+
213+
# Job 3: Verify `@vendure/create` works end-to-end when invoked via bun and pnpm.
214+
# The package manager is detected from npm_config_user_agent (which bunx / pnpm dlx
215+
# set), so the install step and generated project must use that manager rather than
216+
# hard-coding npm (#4390).
217+
test_package_managers:
218+
needs: build_and_publish
219+
runs-on: ubuntu-latest
220+
permissions:
221+
contents: read
222+
strategy:
223+
matrix:
224+
include:
225+
- pm: bun
226+
scaffold: bunx @vendure/create@ci test-app --ci --log-level info
227+
- pm: pnpm
228+
scaffold: pnpm dlx @vendure/create@ci test-app --ci --log-level info
229+
fail-fast: false
230+
steps:
231+
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
232+
- name: Use Node.js 22.x
233+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
234+
with:
235+
node-version: 22.x
236+
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
237+
with:
238+
bun-version: '1.3.10'
239+
- name: Enable pnpm via corepack
240+
run: |
241+
corepack enable
242+
corepack prepare pnpm@9.15.9 --activate
243+
pnpm --version
244+
- name: Download Verdaccio storage
245+
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
246+
with:
247+
name: verdaccio-storage
248+
path: ~/verdaccio-download
249+
- name: Setup Verdaccio with pre-built packages
250+
run: |
251+
npm install -g verdaccio
252+
npm install -g wait-on
253+
mkdir -p $HOME/.config/verdaccio
254+
cp -v ./.github/workflows/verdaccio/config.yaml $HOME/.config/verdaccio/config.yaml
255+
cd $HOME/.config/verdaccio
256+
tar -xzf ~/verdaccio-download/verdaccio-storage.tar.gz
257+
nohup verdaccio --config $HOME/.config/verdaccio/config.yaml &
258+
wait-on http://localhost:4873
259+
# Register a Verdaccio user and capture an auth token.
260+
TOKEN_RES=$(curl -XPUT \
261+
-H "Content-type: application/json" \
262+
-d '{ "name": "test", "password": "test" }' \
263+
'http://localhost:4873/-/user/org.couchdb.user:test')
264+
TOKEN=$(echo "$TOKEN_RES" | jq -r '.token')
265+
# npm + pnpm read ~/.npmrc; bun is pointed at Verdaccio via the
266+
# npm_config_registry env var on the install step (see below).
267+
npm set registry=http://localhost:4873
268+
npm set //localhost:4873/:_authToken $TOKEN
269+
- name: Install via @vendure/create using ${{ matrix.pm }}
270+
# bun ignores the home-directory ~/.npmrc (oven-sh/bun#22971) and does not walk
271+
# up to a parent .npmrc/bunfig, but it DOES honour the npm_config_registry env
272+
# var — and so does the scaffolded project's nested install (it inherits the
273+
# env). This is the one mechanism that reaches both bunx and the nested install.
274+
env:
275+
CI: true
276+
npm_config_registry: http://localhost:4873/
277+
run: |
278+
mkdir -p $HOME/install
279+
cd $HOME/install
280+
npm dist-tag ls @vendure/create
281+
# No --use-npm: the manager is detected from the invoking bunx / pnpm dlx.
282+
${{ matrix.scaffold }}
283+
- name: Assert the generated project used ${{ matrix.pm }}
284+
run: |
285+
cd $HOME/install/test-app
286+
case "${{ matrix.pm }}" in
287+
bun) test -f bun.lock || { echo "Expected bun.lock to exist"; ls -la; exit 1; } ;;
288+
pnpm) test -f pnpm-lock.yaml || { echo "Expected pnpm-lock.yaml to exist"; ls -la; exit 1; } ;;
289+
esac
290+
echo "Lockfile for ${{ matrix.pm }} present ✓"
291+
- name: Server smoke tests
292+
run: |
293+
cd $HOME/install/test-app
294+
${{ matrix.pm }} run dev &
295+
node $GITHUB_WORKSPACE/.github/workflows/scripts/smoke-tests
296+
- name: Kill dev server after smoke tests
297+
if: always()
298+
run: |
299+
lsof -ti:3000,5173 | xargs kill 2>/dev/null || true

packages/create/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"copy-assets": "rimraf assets && ts-node ./build.ts",
2020
"build": "npm run copy-assets && rimraf lib && tsc -p ./tsconfig.build.json",
2121
"watch": "npm run copy-assets && rimraf lib && tsc -p ./tsconfig.build.json -w",
22-
"lint": "eslint --fix ."
22+
"lint": "eslint --fix .",
23+
"test": "vitest --config vitest.config.mts --run"
2324
},
2425
"homepage": "https://www.vendure.io",
2526
"funding": "https://github.com/sponsors/michaelbromley",

packages/create/src/constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ export const TYPESCRIPT_VERSION = '5.8.2';
1818
* bundler with breaking changes.
1919
*/
2020
export const VITE_VERSION = '^7.3.1';
21+
/**
22+
* `concurrently` runs the generated `dev`/`start` scripts. The major is pinned because
23+
* the `<pm>:script:*` shorthand expansion (relied on by both the server scripts and the
24+
* monorepo root) is a feature whose behaviour could change across majors.
25+
*/
26+
export const CONCURRENTLY_VERSION = '^9.0.0';
2127

2228
// Port scanning
2329
export const PORT_SCAN_RANGE = 20;

packages/create/src/create-vendure-app.ts

Lines changed: 48 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,16 @@ import {
3636
checkNodeVersion,
3737
checkThatNpmCanReadCwd,
3838
cleanUpDockerResources,
39+
detectPackageManager,
3940
downloadAndExtractStorefront,
4041
findAvailablePort,
4142
getDependencies,
43+
getMonorepoRootPackageJson,
44+
getPackageManagerInfo,
45+
getServerPackageScripts,
4246
installPackages,
4347
isSafeToCreateProjectIn,
48+
registerTemplateHelpers,
4449
resolvePackageRootDir,
4550
scaffoldAlreadyExists,
4651
startPostgresDatabase,
@@ -76,7 +81,7 @@ program
7681
.option('--verbose', 'Alias for --log-level verbose', false)
7782
.option(
7883
'--use-npm',
79-
'Uses npm rather than as the default package manager. DEPRECATED: Npm is now the default',
84+
'Force npm, overriding auto-detection of the package manager that invoked the CLI',
8085
)
8186
.option('--ci', 'Runs without prompts for use in CI scenarios', false)
8287
.option('--with-storefront', 'Include Next.js storefront (only used with --ci)', false)
@@ -96,7 +101,7 @@ void createVendureApp(
96101

97102
export async function createVendureApp(
98103
name: string | undefined,
99-
_useNpm: boolean, // Deprecated: npm is now the default package manager
104+
_useNpm: boolean, // Legacy flag: forces npm, overriding package-manager auto-detection
100105
logLevel: CliLogLevel,
101106
isCi: boolean = false,
102107
withStorefront: boolean = false,
@@ -144,7 +149,12 @@ export async function createVendureApp(
144149
const appName = path.basename(root);
145150
const scaffoldExists = scaffoldAlreadyExists(root, name);
146151

147-
const packageManager: PackageManager = 'npm';
152+
// `--use-npm` is honoured as an explicit override of auto-detection; otherwise we
153+
// detect the manager that invoked the CLI (bunx/pnpm dlx/yarn dlx) so the generated
154+
// project, install step and instructions all match what the user is actually using.
155+
const packageManager: PackageManager = _useNpm ? 'npm' : detectPackageManager();
156+
const pmInfo = getPackageManagerInfo(packageManager);
157+
registerTemplateHelpers(pmInfo);
148158

149159
if (scaffoldExists) {
150160
log(
@@ -196,7 +206,8 @@ export async function createVendureApp(
196206
}
197207

198208
process.chdir(root);
199-
if (packageManager !== 'npm' && !checkThatNpmCanReadCwd()) {
209+
// This check spawns `npm` itself, so it only makes sense (and only works) for npm.
210+
if (packageManager === 'npm' && !checkThatNpmCanReadCwd()) {
200211
process.exit(1);
201212
}
202213

@@ -215,15 +226,23 @@ export async function createVendureApp(
215226
await fs.ensureDir(serverRoot);
216227
await fs.ensureDir(path.join(serverRoot, 'src'));
217228

218-
// Generate root package.json from template
219-
const rootPackageTemplate = await fs.readFile(templatePath('root-package.json.hbs'), 'utf-8');
220-
const rootPackageContent = Handlebars.compile(rootPackageTemplate)({ name: appName });
221-
fs.writeFileSync(path.join(root, 'package.json'), rootPackageContent + os.EOL);
229+
// Generate root package.json with package-manager-aware workspace scripts
230+
fs.writeFileSync(
231+
path.join(root, 'package.json'),
232+
JSON.stringify(getMonorepoRootPackageJson(appName, pmInfo), null, 2) + os.EOL,
233+
);
234+
235+
// pnpm does not read the package.json `workspaces` field; it requires a
236+
// pnpm-workspace.yaml instead.
237+
if (!pmInfo.usesPackageJsonWorkspaces) {
238+
fs.writeFileSync(path.join(root, 'pnpm-workspace.yaml'), `packages:\n - 'apps/*'\n`);
239+
}
222240

223241
// Generate root README from template
224242
const rootReadmeTemplate = await fs.readFile(templatePath('root-readme.hbs'), 'utf-8');
225243
const rootReadmeContent = Handlebars.compile(rootReadmeTemplate)({
226244
name: appName,
245+
packageManager,
227246
serverPort: port,
228247
storefrontPort,
229248
superadminIdentifier: SUPER_ADMIN_USER_IDENTIFIER,
@@ -239,7 +258,7 @@ export async function createVendureApp(
239258
name: 'server',
240259
version: DEFAULT_PROJECT_VERSION,
241260
private: true,
242-
scripts: getServerPackageScripts(),
261+
scripts: getServerPackageScripts(pmInfo),
243262
};
244263
fs.writeFileSync(
245264
path.join(serverRoot, 'package.json'),
@@ -251,7 +270,7 @@ export async function createVendureApp(
251270
name: appName,
252271
version: DEFAULT_PROJECT_VERSION,
253272
private: true,
254-
scripts: getServerPackageScripts(),
273+
scripts: getServerPackageScripts(pmInfo),
255274
};
256275
fs.writeFileSync(
257276
path.join(root, 'package.json'),
@@ -302,6 +321,7 @@ export async function createVendureApp(
302321
// Install server dependencies
303322
await installDependenciesWithSpinner({
304323
dependencies,
324+
packageManager,
305325
logLevel,
306326
cwd: serverRoot,
307327
spinnerMessage: `Installing ${dependencies[0]} + ${dependencies.length - 1} more dependencies`,
@@ -313,6 +333,7 @@ export async function createVendureApp(
313333
await installDependenciesWithSpinner({
314334
dependencies: devDependencies,
315335
isDevDependencies: true,
336+
packageManager,
316337
logLevel,
317338
cwd: serverRoot,
318339
spinnerMessage: `Installing ${devDependencies[0]} + ${devDependencies.length - 1} more dev dependencies`,
@@ -325,6 +346,7 @@ export async function createVendureApp(
325346
// Install storefront dependencies
326347
const storefrontInstalled = await installDependenciesWithSpinner({
327348
dependencies: [],
349+
packageManager,
328350
logLevel,
329351
cwd: storefrontRoot,
330352
spinnerMessage: 'Installing storefront dependencies...',
@@ -333,7 +355,9 @@ export async function createVendureApp(
333355
warnOnFailure: true,
334356
});
335357
if (!storefrontInstalled) {
336-
log('You may need to run npm install in the storefront directory manually.', { level: 'info' });
358+
log(`You may need to run ${pmInfo.install} in the storefront directory manually.`, {
359+
level: 'info',
360+
});
337361
}
338362
}
339363

@@ -511,10 +535,13 @@ export async function createVendureApp(
511535
];
512536
note(quickStartInstructions.join('\n'));
513537

514-
const npmCommand = os.platform() === 'win32' ? 'npm.cmd' : 'npm';
538+
// Run `dev` via the detected package manager. On Windows the npm/yarn/pnpm
539+
// binaries are `.cmd` shims (bun ships a real `bun.exe`).
540+
const pmCommand =
541+
os.platform() === 'win32' && pmInfo.name !== 'bun' ? `${pmInfo.name}.cmd` : pmInfo.name;
515542
let quickStartProcess: ChildProcess | undefined;
516543
try {
517-
quickStartProcess = spawn(npmCommand, ['run', 'dev'], {
544+
quickStartProcess = spawn(pmCommand, ['run', 'dev'], {
518545
cwd: root,
519546
stdio: 'inherit',
520547
});
@@ -527,6 +554,7 @@ export async function createVendureApp(
527554
displayOutro({
528555
root,
529556
name,
557+
packageManager,
530558
superAdminCredentials,
531559
includeStorefront,
532560
serverPort: port,
@@ -557,6 +585,7 @@ export async function createVendureApp(
557585
displayOutro({
558586
root,
559587
name,
588+
packageManager,
560589
superAdminCredentials,
561590
includeStorefront,
562591
serverPort: port,
@@ -571,26 +600,10 @@ export async function createVendureApp(
571600
}
572601
}
573602

574-
/**
575-
* Returns the standard npm scripts for the server package.json.
576-
*/
577-
function getServerPackageScripts(): Record<string, string> {
578-
return {
579-
'dev:server': 'ts-node ./src/index.ts',
580-
'dev:worker': 'ts-node ./src/index-worker.ts',
581-
'dev:dashboard': 'vite --clearScreen false',
582-
dev: 'concurrently --kill-others npm:dev:*',
583-
build: 'tsc',
584-
'build:dashboard': 'vite build',
585-
'start:server': 'node ./dist/index.js',
586-
'start:worker': 'node ./dist/index-worker.js',
587-
start: 'concurrently npm:start:*',
588-
};
589-
}
590-
591603
interface InstallDependenciesOptions {
592604
dependencies: string[];
593605
isDevDependencies?: boolean;
606+
packageManager: PackageManager;
594607
logLevel: CliLogLevel;
595608
cwd: string;
596609
spinnerMessage: string;
@@ -607,6 +620,7 @@ async function installDependenciesWithSpinner(installOptions: InstallDependencie
607620
const {
608621
dependencies,
609622
isDevDependencies = false,
623+
packageManager,
610624
logLevel,
611625
cwd,
612626
spinnerMessage,
@@ -619,7 +633,7 @@ async function installDependenciesWithSpinner(installOptions: InstallDependencie
619633
installSpinner.start(spinnerMessage);
620634

621635
try {
622-
await installPackages({ dependencies, isDevDependencies, logLevel, cwd });
636+
await installPackages({ dependencies, isDevDependencies, packageManager, logLevel, cwd });
623637
installSpinner.stop(successMessage);
624638
return true;
625639
} catch (e) {
@@ -636,6 +650,7 @@ async function installDependenciesWithSpinner(installOptions: InstallDependencie
636650
interface OutroOptions {
637651
root: string;
638652
name: string;
653+
packageManager: PackageManager;
639654
superAdminCredentials?: { identifier: string; password: string };
640655
includeStorefront?: boolean;
641656
serverPort?: number;
@@ -647,12 +662,13 @@ function displayOutro(outroOptions: OutroOptions) {
647662
const {
648663
root,
649664
name,
665+
packageManager,
650666
superAdminCredentials,
651667
includeStorefront,
652668
serverPort = SERVER_PORT,
653669
storefrontPort = STOREFRONT_PORT,
654670
} = outroOptions;
655-
const startCommand = 'npm run dev';
671+
const startCommand = `${getPackageManagerInfo(packageManager).runScript} dev`;
656672
const identifier = superAdminCredentials?.identifier ?? SUPER_ADMIN_USER_IDENTIFIER;
657673
const password = superAdminCredentials?.password ?? SUPER_ADMIN_USER_PASSWORD;
658674

packages/create/src/gather-user-responses.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,9 @@ async function generateSources(
300300
cookieSecret: randomBytes(16).toString('base64url'),
301301
port,
302302
isMonorepo: answers.includeStorefront,
303+
packageManager,
304+
isBun: packageManager === 'bun',
305+
needsCorepack: packageManager === 'pnpm' || packageManager === 'yarn',
303306
};
304307

305308
async function createSourceFile(filename: string, noEscape = false): Promise<string> {

0 commit comments

Comments
 (0)