From 6928f538ddcca6297d0fac0edbfcce37b095629d Mon Sep 17 00:00:00 2001 From: Paul Bratslavsky Date: Tue, 8 Sep 2026 11:26:32 -0700 Subject: [PATCH 01/11] Move create-launchpad-app into the repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI scaffolds this repo and offers a --framework flag, so it carries its own copy of the frontend list: names, ports and dev scripts. It has to be a copy, because the CLI runs before the repo is cloned. Kept in a separate repo, that copy drifts silently. Rename a frontend or change a port here and the CLI keeps offering the old one, then checks the wrong port or calls a dev script that no longer exists — a failure the user hits, not us. In-repo, a test can read the real sources. cli/test/registry-matches-launchpad.test.js checks the CLI's registry against scripts/frontends.mts, the root package.json scripts and the frontend directories themselves. Verified it catches drift: changing astro's port in the CLI fails with "CLI says port 9999, scripts/frontends.mts says 4321". cli/ uses npm rather than yarn. It is published to npm separately and is not part of the yarn setup that installs the frontends, so `yarn cli` and `yarn test:cli` install its dependencies on demand — both work from a fresh clone with no extra step. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XZNfEU8h231jo1hu7kcviZ --- AGENTS.md | 2 + README.md | 12 + cli/.gitignore | 5 + cli/LICENSE | 21 + cli/README.md | 82 +++ cli/bin/cli.js | 55 ++ cli/package-lock.json | 600 ++++++++++++++++++++ cli/package.json | 42 ++ cli/src/commands/create.js | 266 +++++++++ cli/src/frameworks.js | 35 ++ cli/src/utils/logger.js | 10 + cli/src/utils/ports.js | 17 + cli/src/utils/prerequisites.js | 62 ++ cli/test/frameworks.test.js | 49 ++ cli/test/registry-matches-launchpad.test.js | 77 +++ package.json | 2 + 16 files changed, 1337 insertions(+) create mode 100644 cli/.gitignore create mode 100644 cli/LICENSE create mode 100644 cli/README.md create mode 100755 cli/bin/cli.js create mode 100644 cli/package-lock.json create mode 100644 cli/package.json create mode 100644 cli/src/commands/create.js create mode 100644 cli/src/frameworks.js create mode 100644 cli/src/utils/logger.js create mode 100644 cli/src/utils/ports.js create mode 100644 cli/src/utils/prerequisites.js create mode 100644 cli/test/frameworks.test.js create mode 100644 cli/test/registry-matches-launchpad.test.js diff --git a/AGENTS.md b/AGENTS.md index f8e1b050..80f88db2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ LaunchPad is the official Strapi demo app. - `astro/`: Astro frontend, same content and routes. - `nuxt/`: Nuxt 4 frontend, same content and routes. - `tanstack/`: TanStack Start frontend, same content and routes. +- `cli/`: `create-launchpad-app`, the npm package that scaffolds this repo. Uses npm, not yarn, since it is published separately. - Root: setup/dev/format scripts using Yarn 4.5.0. Each directory keeps its own lockfile; this is not a Yarn workspace. ## First Read @@ -38,6 +39,7 @@ Run commands from the correct directory. - Strapi dev: `cd strapi && yarn develop` - Strapi build: `cd strapi && yarn build` - A frontend directly: `cd && yarn dev` +- CLI tests: `yarn test:cli` (verifies the CLI's frontend list still matches this repo) ## Setup diff --git a/README.md b/README.md index 082484c9..f50534d5 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,18 @@ Strap yourself in! You can get started with this project on your local machine b > npm install -g yarn > ``` +## Quickest start + +```bash +npx create-launchpad-app my-app +``` + +It asks which frontend you want, then clones, installs, seeds and starts it. +Pass `--framework astro` (or `nuxt`, `tanstack`, `next`) to skip the question. +The CLI lives in [`cli/`](cli/). + +To set the project up by hand instead, carry on below. + ## 1. Clone and Install To infinity and beyond! Clone the repo and install root dependencies: diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 00000000..71c10602 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +*.log + +# scratch output from manual CLI runs +/tmp-*/ diff --git a/cli/LICENSE b/cli/LICENSE new file mode 100644 index 00000000..d9c43658 --- /dev/null +++ b/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Paul Bratslavsky + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000..da2ab91d --- /dev/null +++ b/cli/README.md @@ -0,0 +1,82 @@ +# create-launchpad-app + +Scaffold [LaunchPad](https://github.com/strapi/LaunchPad), Strapi's official +demo application, with the frontend you want to work in. + +```bash +npx create-launchpad-app my-app +``` + +That clones LaunchPad, installs it, seeds the demo content, and starts the +dev servers. With no `--framework` it asks which frontend to run. + +## Frontends + +LaunchPad ships four frontends against one Strapi backend. + +| Frontend | Flag | Port | +| -------------- | ---------------------------- | ---- | +| Next.js | `--framework next` (default) | 3000 | +| Nuxt 4 | `--framework nuxt` | 3001 | +| TanStack Start | `--framework tanstack` | 3002 | +| Astro | `--framework astro` | 4321 | + +Strapi runs on **1337** for all of them. + +```bash +npx create-launchpad-app my-app --framework astro +``` + +## Options + +| Option | Description | +| ------------------------ | ---------------------------------------------------- | +| `-f, --framework ` | Frontend to run. Prompts if omitted. | +| `-r, --ref ` | Branch or tag of the LaunchPad repo to clone. | +| `--no-seed` | Skip seeding the demo content. | +| `--no-start` | Set everything up, but do not start the dev servers. | +| `--no-git` | Do not initialize a git repository. | +| `--dry-run` | Print what would happen and exit. | + +## Requirements + +- Node.js 20.19 or newer +- Git + +Yarn is required — LaunchPad pins `yarn@4.5.0` in its root `package.json`, so +npm and pnpm will not work. If yarn is missing, the CLI enables it through +Corepack for you. + +## What it does + +1. Clones LaunchPad (shallow) and removes its history +2. Initializes a fresh git repository with one commit +3. `yarn install && yarn setup` — installs every workspace and writes `.env` files +4. `yarn seed` — imports the demo content into SQLite +5. `yarn dev` (or `dev:astro`, `dev:nuxt`, `dev:tanstack`) + +Use `--dry-run` to see the plan for any combination of flags without touching +the disk. + +## Development + +```bash +npm install +npm test +node ./bin/cli.js my-app --dry-run +``` + +Frontends are declared in one place, [`src/frameworks.js`](src/frameworks.js). + +It has to be a separate copy, because the CLI runs before the repo is cloned. +That copy can drift, and when it does the CLI checks the wrong port or calls a +dev script that does not exist. `test/registry-matches-launchpad.test.js` +reads the real sources — `../scripts/frontends.mts`, the root `package.json` +scripts and the frontend directories — and fails if they disagree. + +That test is why this package lives in the LaunchPad repo rather than its own. +Run it from the repo root with `yarn test:cli`. + +## License + +MIT diff --git a/cli/bin/cli.js b/cli/bin/cli.js new file mode 100755 index 00000000..b895e691 --- /dev/null +++ b/cli/bin/cli.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import { program } from 'commander'; +import { createRequire } from 'node:module'; + +import { createLaunchpadApp } from '../src/commands/create.js'; +import { FRAMEWORKS, frameworkNames } from '../src/frameworks.js'; + +// Read the version from package.json rather than repeating it here, so the +// two cannot drift. +const require = createRequire(import.meta.url); +const { version } = require('../package.json'); + +const frameworkList = FRAMEWORKS.map( + (f) => ` ${f.name.padEnd(9)} ${f.label} (port ${f.port})` +).join('\n'); + +program + .name('create-launchpad-app') + .description('Scaffold the official Strapi LaunchPad demo application') + .version(version) + .argument('[directory]', 'directory to create the project in', 'launchpad') + .option( + '-f, --framework ', + `frontend to run (${frameworkNames().join(', ')}) — prompts if omitted` + ) + .option('-r, --ref ', 'branch or tag of the LaunchPad repo to clone') + .option('--no-seed', 'skip seeding demo data') + .option('--no-start', 'skip starting dev servers after setup') + .option('--no-git', 'skip initializing a git repository') + .option('--dry-run', 'print what would happen without doing it') + .addHelpText( + 'after', + ` +Frontends: +${frameworkList} + +Examples: + $ create-launchpad-app my-app + $ create-launchpad-app my-app --framework astro + $ create-launchpad-app my-app --framework nuxt --no-start + $ create-launchpad-app my-app --dry-run + +All frontends share one Strapi backend on port ${1337}. +` + ) + .action(async (directory, options) => { + try { + await createLaunchpadApp(directory, options); + } catch (error) { + console.error(error?.message ?? error); + process.exit(1); + } + }); + +program.parse(); diff --git a/cli/package-lock.json b/cli/package-lock.json new file mode 100644 index 00000000..a54878bb --- /dev/null +++ b/cli/package-lock.json @@ -0,0 +1,600 @@ +{ + "name": "create-launchpad-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "create-launchpad-app", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@clack/prompts": "^1.8.0", + "chalk": "^5.4.1", + "commander": "^13.1.0", + "execa": "^9.5.2", + "ora": "^8.2.0" + }, + "bin": { + "create-launchpad-app": "bin/cli.js" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@clack/core": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.0.tgz", + "integrity": "sha512-zNikCcd8BbcEvzzG1sbXFrRHFk5kHPrpwZwksPvf9qyQO1Teb7JaXaOAxXZei9nZLDW0gaZawiuTCji88bTBhw==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.0.tgz", + "integrity": "sha512-PXzLZ8N34rxmuo4dJg3xtOXhcBse94qGjDqsteoEYrFrrZ5FSjIGwMAuOcv64ln8rHVBBD06XeVGr+/JX+plcA==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.5.0", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 00000000..cfaa92a5 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,42 @@ +{ + "name": "create-launchpad-app", + "version": "1.0.0", + "description": "CLI to scaffold the official Strapi LaunchPad demo application with the frontend of your choice", + "type": "module", + "bin": { + "create-launchpad-app": "./bin/cli.js" + }, + "scripts": { + "dev": "node ./bin/cli.js", + "test": "node --test \"test/**/*.test.js\"" + }, + "keywords": [ + "strapi", + "launchpad", + "demo", + "cli", + "scaffold", + "astro", + "nuxt", + "tanstack", + "nextjs" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@clack/prompts": "^1.8.0", + "chalk": "^5.4.1", + "commander": "^13.1.0", + "execa": "^9.5.2", + "ora": "^8.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "files": [ + "bin", + "src", + "README.md", + "LICENSE" + ] +} diff --git a/cli/src/commands/create.js b/cli/src/commands/create.js new file mode 100644 index 00000000..582899ff --- /dev/null +++ b/cli/src/commands/create.js @@ -0,0 +1,266 @@ +import * as p from '@clack/prompts'; +import chalk from 'chalk'; +import { execa } from 'execa'; +import fs from 'node:fs'; +import path from 'node:path'; +import ora from 'ora'; + +import { + DEFAULT_FRAMEWORK, + FRAMEWORKS, + STRAPI_PORT, + frameworkNames, + getFramework, +} from '../frameworks.js'; +import { log } from '../utils/logger.js'; +import { isPortAvailable } from '../utils/ports.js'; +import { checkPrerequisites } from '../utils/prerequisites.js'; + +const REPO_URL = 'https://github.com/strapi/LaunchPad.git'; + +// LaunchPad pins yarn in its root package.json, so Corepack will refuse any +// other package manager. There is no point offering a choice. +const PM = 'yarn'; + +/** + * Which frontend to scaffold: the flag if given, otherwise a prompt. + * + * An unknown --framework is rejected rather than silently falling back, so a + * typo in a script fails loudly. + */ +async function resolveFramework(flag) { + if (flag) { + const framework = getFramework(flag); + if (!framework) { + log.error( + `Unknown framework "${flag}". Expected one of: ${frameworkNames().join(', ')}` + ); + process.exit(1); + } + return framework; + } + + // Not a TTY (CI, piped input) — prompting would hang. + if (!process.stdin.isTTY) return getFramework(DEFAULT_FRAMEWORK); + + const choice = await p.select({ + message: 'Which frontend would you like to run?', + options: FRAMEWORKS.map((f) => ({ + value: f.name, + label: f.label, + hint: `port ${f.port}`, + })), + initialValue: DEFAULT_FRAMEWORK, + }); + + if (p.isCancel(choice)) { + p.cancel('Cancelled.'); + process.exit(0); + } + + return getFramework(choice); +} + +/** + * Fails early when the cloned ref predates the chosen frontend. + * + * Without this the run gets several steps further before `yarn dev:astro` + * fails with something far less obvious. + */ +function assertFrameworkPresent(targetDir, framework, ref) { + if (framework.name === DEFAULT_FRAMEWORK) return; + if (fs.existsSync(path.join(targetDir, framework.name))) return; + + const available = FRAMEWORKS.filter((f) => + fs.existsSync(path.join(targetDir, f.name)) + ).map((f) => f.name); + + console.log(); + log.error( + `This LaunchPad ref has no ${framework.name}/ directory, so --framework ${framework.name} cannot work.` + ); + console.log(` Available here: ${available.join(', ') || 'none'}`); + console.log( + ` Try a ref that includes it, e.g. --ref feat/tanstack-frontend` + ); + console.log(); + process.exit(1); +} + +function printPlan(targetDir, framework, options, ref) { + const steps = [ + `clone ${REPO_URL}${ref ? ` (ref ${ref})` : ''} into ${targetDir}`, + options.git ? 'git init' : 'skip git init (--no-git)', + `${PM} install && ${PM} setup`, + options.seed ? `${PM} seed` : 'skip seed (--no-seed)', + options.start + ? `${PM} ${framework.devScript} → Strapi :${STRAPI_PORT}, ${framework.label} :${framework.port}` + : 'skip dev start (--no-start)', + ]; + + console.log(); + log.info(`Plan for ${chalk.bold(framework.label)}:`); + steps.forEach((s, i) => console.log(` ${chalk.cyan(`${i + 1}.`)} ${s}`)); + console.log(); + log.info('Dry run — nothing was changed.'); + console.log(); +} + +export async function createLaunchpadApp(directory, options) { + const targetDir = path.resolve(process.cwd(), directory); + const ref = options.ref; + + console.log(); + const framework = await resolveFramework(options.framework); + + if (options.dryRun) { + printPlan(targetDir, framework, options, ref); + return; + } + + log.info(`Creating LaunchPad app in ${targetDir}`); + log.info(`Frontend: ${chalk.bold(framework.label)}\n`); + + await checkPrerequisites(PM); + console.log(); + + // Steps vary with the flags, so count them rather than hardcoding a total + // that drifts every time one becomes conditional. + const stepLabels = [ + 'Cloning LaunchPad repository', + 'Installing dependencies', + options.seed && 'Seeding demo data', + options.start ? 'Starting development servers' : 'Finishing up', + ].filter(Boolean); + const total = stepLabels.length; + let step = 0; + const nextStep = (msg) => log.step(++step, total, msg); + + // --- Clone --- + nextStep('Cloning LaunchPad repository...'); + + if (fs.existsSync(targetDir)) { + log.error( + `Directory "${directory}" already exists. Pick a different name.` + ); + process.exit(1); + } + + const cloneSpinner = ora('Cloning...').start(); + try { + const args = ['clone', '--depth=1']; + if (ref) args.push('--branch', ref); + args.push(REPO_URL, targetDir); + await execa('git', args); + fs.rmSync(path.join(targetDir, '.git'), { recursive: true, force: true }); + cloneSpinner.succeed(`Repository cloned${ref ? ` (${ref})` : ''}`); + } catch (error) { + cloneSpinner.fail('Failed to clone repository'); + log.error( + ref + ? `Could not clone ref "${ref}". Check the branch or tag exists.` + : error.message + ); + process.exit(1); + } + + assertFrameworkPresent(targetDir, framework, ref); + + // The clone's history is removed above, so give the user a repo of their own + // rather than leaving the directory untracked. + if (options.git) { + try { + await execa('git', ['init', '-q'], { cwd: targetDir }); + await execa('git', ['add', '-A'], { cwd: targetDir }); + await execa( + 'git', + ['commit', '-q', '-m', 'Initial commit from create-launchpad-app'], + { cwd: targetDir } + ); + log.success('Initialized a git repository'); + } catch { + // A missing git identity is the usual cause. Not worth failing the run. + log.warn('Could not create the initial commit — continuing'); + } + } + + // --- Install --- + nextStep('Installing dependencies...'); + const installSpinner = ora('Running setup...').start(); + try { + await execa(PM, ['install'], { cwd: targetDir }); + await execa(PM, ['setup'], { cwd: targetDir }); + installSpinner.succeed('Dependencies installed'); + } catch (error) { + installSpinner.fail('Failed to install dependencies'); + log.error(error.message); + process.exit(1); + } + + // --- Seed --- + if (options.seed) { + nextStep('Seeding demo data...'); + const seedSpinner = ora('Seeding...').start(); + try { + await execa(PM, ['seed'], { cwd: targetDir }); + seedSpinner.succeed('Demo data seeded'); + } catch (error) { + seedSpinner.fail('Failed to seed data'); + log.error(error.message); + process.exit(1); + } + } + + // --- Start --- + if (!options.start) { + nextStep('Finishing up'); + console.log(); + log.success('LaunchPad is ready! To start:'); + console.log(`\n cd ${directory}`); + console.log(` ${PM} ${framework.devScript}\n`); + return; + } + + nextStep('Starting development servers...'); + + const blocked = []; + if (!(await isPortAvailable(STRAPI_PORT))) { + blocked.push(`${STRAPI_PORT} (Strapi)`); + } + if (!(await isPortAvailable(framework.port))) { + blocked.push(`${framework.port} (${framework.label})`); + } + + if (blocked.length > 0) { + console.log(); + log.error(`Port ${blocked.join(' and ')} already in use.`); + console.log(); + console.log( + ' Either stop whatever is using them, or change the ports in:' + ); + console.log(` ${directory}/strapi/.env → Strapi`); + console.log( + ` ${directory}/${framework.name}/.env${' '.repeat(Math.max(0, 12 - framework.name.length))} → ${framework.label}` + ); + console.log(); + console.log(' Then start it yourself:'); + console.log(` cd ${directory}`); + console.log(` ${PM} ${framework.devScript}`); + console.log(); + process.exit(1); + } + + console.log(); + log.info(`Strapi admin → http://localhost:${STRAPI_PORT}/admin`); + log.info(`${framework.label} → http://localhost:${framework.port}`); + console.log(); + + try { + await execa(PM, [framework.devScript], { + cwd: targetDir, + stdio: 'inherit', + }); + } catch { + // Ctrl+C is the normal way out of a dev server. + } +} diff --git a/cli/src/frameworks.js b/cli/src/frameworks.js new file mode 100644 index 00000000..6c7ea6d0 --- /dev/null +++ b/cli/src/frameworks.js @@ -0,0 +1,35 @@ +/** + * The frontends LaunchPad ships. + * + * This mirrors `scripts/frontends.mts` in the LaunchPad repo. Keep the two in + * step: the ports and dev scripts here have to match what that repo actually + * runs, or the CLI will check the wrong port and call a script that does not + * exist. + * + * Adding a frontend should be one entry — the prompt, the `--framework` + * choices, the port check, the dev command and the help text all read from + * this list. + */ +export const FRAMEWORKS = [ + { name: 'next', label: 'Next.js', port: 3000, devScript: 'dev' }, + { name: 'astro', label: 'Astro', port: 4321, devScript: 'dev:astro' }, + { name: 'nuxt', label: 'Nuxt 4', port: 3001, devScript: 'dev:nuxt' }, + { + name: 'tanstack', + label: 'TanStack Start', + port: 3002, + devScript: 'dev:tanstack', + }, +]; + +/** Port Strapi listens on. Shared by every frontend. */ +export const STRAPI_PORT = 1337; + +/** The frontend used when none is chosen. */ +export const DEFAULT_FRAMEWORK = 'next'; + +export const frameworkNames = () => FRAMEWORKS.map((f) => f.name); + +export function getFramework(name) { + return FRAMEWORKS.find((f) => f.name === name) ?? null; +} diff --git a/cli/src/utils/logger.js b/cli/src/utils/logger.js new file mode 100644 index 00000000..66c97aa6 --- /dev/null +++ b/cli/src/utils/logger.js @@ -0,0 +1,10 @@ +import chalk from 'chalk'; + +export const log = { + info: (msg) => console.log(chalk.blue('ℹ'), msg), + success: (msg) => console.log(chalk.green('✔'), msg), + warn: (msg) => console.log(chalk.yellow('⚠'), msg), + error: (msg) => console.error(chalk.red('✖'), msg), + step: (step, total, msg) => + console.log(chalk.cyan(`[${step}/${total}]`), msg), +}; diff --git a/cli/src/utils/ports.js b/cli/src/utils/ports.js new file mode 100644 index 00000000..c24a354b --- /dev/null +++ b/cli/src/utils/ports.js @@ -0,0 +1,17 @@ +import net from 'node:net'; + +/** + * Check if a port is available. + * Returns true if available, false if in use. + */ +export function isPortAvailable(port) { + return new Promise((resolve) => { + const server = net.createServer(); + server.unref(); // Prevent server from keeping the process alive + server.once('error', () => resolve(false)); + server.once('listening', () => { + server.close(() => resolve(true)); + }); + server.listen(port); + }); +} diff --git a/cli/src/utils/prerequisites.js b/cli/src/utils/prerequisites.js new file mode 100644 index 00000000..b0d0095e --- /dev/null +++ b/cli/src/utils/prerequisites.js @@ -0,0 +1,62 @@ +import { execaCommand } from 'execa'; + +import { log } from './logger.js'; + +// LaunchPad's frontends need this: Nuxt 4 and Astro 6 both require 20.19+. +// The old floor of 18 let people through to a confusing failure during install. +const MIN_NODE = [20, 19, 0]; + +function parseVersion(version) { + return version.split('.').map((n) => parseInt(n, 10)); +} + +function isAtLeast(actual, required) { + for (let i = 0; i < required.length; i++) { + const a = actual[i] ?? 0; + if (a > required[i]) return true; + if (a < required[i]) return false; + } + return true; +} + +export async function checkPrerequisites() { + const nodeVersion = process.versions.node; + + if (!isAtLeast(parseVersion(nodeVersion), MIN_NODE)) { + log.error( + `Node.js ${MIN_NODE.join('.')} or higher is required. You are running v${nodeVersion}.` + ); + process.exit(1); + } + log.success(`Node.js v${nodeVersion}`); + + try { + await execaCommand('git --version'); + } catch { + log.error('Git is not installed. Install it and try again.'); + process.exit(1); + } + + // LaunchPad pins yarn@4.5.0 in its root package.json, so this is the only + // package manager that will work. Corepack ships with Node and can provide + // it without a global install. + try { + await execaCommand('yarn --version'); + log.success('Yarn available'); + } catch { + log.warn('Yarn not found — enabling it through corepack...'); + try { + await execaCommand('corepack enable'); + log.success('Corepack enabled, yarn is now available'); + } catch { + log.error( + 'Could not enable yarn. Install it manually:\n' + + ' corepack enable\n' + + ' — or —\n' + + ' npm install -g yarn\n\n' + + 'LaunchPad pins yarn@4.5.0, so npm and pnpm will not work.' + ); + process.exit(1); + } + } +} diff --git a/cli/test/frameworks.test.js b/cli/test/frameworks.test.js new file mode 100644 index 00000000..2a8f7436 --- /dev/null +++ b/cli/test/frameworks.test.js @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + DEFAULT_FRAMEWORK, + FRAMEWORKS, + STRAPI_PORT, + frameworkNames, + getFramework, +} from '../src/frameworks.js'; + +test('every framework is fully described', () => { + for (const f of FRAMEWORKS) { + assert.ok(f.name, 'name'); + assert.ok(f.label, `label for ${f.name}`); + assert.equal(typeof f.port, 'number', `port for ${f.name}`); + assert.ok(f.devScript, `devScript for ${f.name}`); + } +}); + +test('ports are unique and never collide with Strapi', () => { + const ports = FRAMEWORKS.map((f) => f.port); + assert.equal(new Set(ports).size, ports.length, 'ports must be unique'); + assert.ok(!ports.includes(STRAPI_PORT), 'no frontend may use Strapi’s port'); +}); + +test('the default framework exists', () => { + assert.ok(getFramework(DEFAULT_FRAMEWORK)); +}); + +test('getFramework returns null for an unknown name', () => { + assert.equal(getFramework('svelte'), null); +}); + +test('only next uses the bare dev script', () => { + // Every other frontend needs its own script, or the CLI would silently + // start Next while claiming to start something else. + for (const f of FRAMEWORKS) { + if (f.name === 'next') assert.equal(f.devScript, 'dev'); + else assert.equal(f.devScript, `dev:${f.name}`); + } +}); + +test('frameworkNames matches the registry', () => { + assert.deepEqual( + frameworkNames(), + FRAMEWORKS.map((f) => f.name) + ); +}); diff --git a/cli/test/registry-matches-launchpad.test.js b/cli/test/registry-matches-launchpad.test.js new file mode 100644 index 00000000..75b5164b --- /dev/null +++ b/cli/test/registry-matches-launchpad.test.js @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { FRAMEWORKS, STRAPI_PORT } from '../src/frameworks.js'; + +/** + * Guards the reason this CLI lives in the LaunchPad repo. + * + * The CLI keeps its own copy of the frontend list because it has to work + * before the repo is cloned. That copy can drift from the real one, and when + * it does the CLI checks the wrong port or calls a dev script that does not + * exist — a failure the user sees, not us. + * + * These tests read the actual sources rather than a second copy of the + * expected values, so they fail when LaunchPad changes and the CLI does not. + */ + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, '..', '..'); + +const read = (rel) => fs.readFileSync(path.join(repoRoot, rel), 'utf8'); + +test('every frontend the CLI offers exists in the repo', () => { + for (const f of FRAMEWORKS) { + const dir = path.join(repoRoot, f.name); + assert.ok( + fs.existsSync(path.join(dir, 'package.json')), + `${f.name}/ is offered by the CLI but not present in the repo` + ); + } +}); + +test('every dev script the CLI calls exists in the root package.json', () => { + const scripts = JSON.parse(read('package.json')).scripts ?? {}; + for (const f of FRAMEWORKS) { + assert.ok( + f.devScript in scripts, + `the CLI would run "yarn ${f.devScript}" for ${f.name}, but that script does not exist` + ); + } +}); + +test('ports match scripts/frontends.mts', () => { + // The registry is TypeScript, so read the declarations rather than import. + const source = read('scripts/frontends.mts'); + const declared = new Map( + [...source.matchAll(/define\(\s*'([^']+)',\s*'[^']*',\s*(\d+)/g)].map( + ([, name, port]) => [name, Number(port)] + ) + ); + + assert.ok(declared.size > 0, 'could not parse scripts/frontends.mts'); + + for (const f of FRAMEWORKS) { + assert.equal( + declared.get(f.name), + f.port, + `${f.name}: CLI says port ${f.port}, scripts/frontends.mts says ${declared.get(f.name)}` + ); + } + + assert.deepEqual( + [...declared.keys()].sort(), + FRAMEWORKS.map((f) => f.name).sort(), + 'the CLI and scripts/frontends.mts list different frontends' + ); +}); + +test('Strapi port matches strapi/.env.example', () => { + const example = read('strapi/.env.example'); + const match = /^PORT=(\d+)/m.exec(example); + if (!match) return; // not pinned there; nothing to check + assert.equal(Number(match[1]), STRAPI_PORT); +}); diff --git a/package.json b/package.json index ee9291f6..39d04345 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "dev:tanstack": "node --import tsx ./scripts/dev.mts tanstack", "use": "node --import tsx ./scripts/use.mts", "check:env": "node --import tsx ./scripts/check-env.mts", + "cli": "cd cli && npm install --silent --no-audit --no-fund && node bin/cli.js", + "test:cli": "cd cli && npm install --silent --no-audit --no-fund && npm test", "next": "yarn dev --prefix ../next/", "strapi": "yarn dev --prefix ../strapi/", "seed": "cd strapi && yarn strapi import -f ./data/export_20250116105447.tar.gz --force", From 616b9e71ba4e9879469822c9b5bc31f18c179b63 Mon Sep 17 00:00:00 2001 From: Paul Bratslavsky Date: Tue, 8 Sep 2026 11:47:22 -0700 Subject: [PATCH 02/11] Prompt with the frontends the clone actually has The prompt listed all four frontends regardless of which ref was being cloned, so choosing Astro against main got you an error explaining that Astro is not there. Offering a choice and then refusing it is worse than not offering it. The clone now happens first and the prompt is built from the frontend directories found in it. Against main that is next alone, so the prompt is skipped entirely rather than showing a one-item menu. Against a ref with all four, all four are offered. --framework is validated against the same list, and distinguishes a frontend that exists but is missing from this ref ("try --ref ...") from a name that is not a frontend at all. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XZNfEU8h231jo1hu7kcviZ --- cli/src/commands/create.js | 92 ++++++++++++++++++++++---------------- 1 file changed, 54 insertions(+), 38 deletions(-) diff --git a/cli/src/commands/create.js b/cli/src/commands/create.js index 582899ff..5d34ab1a 100644 --- a/cli/src/commands/create.js +++ b/cli/src/commands/create.js @@ -23,34 +23,56 @@ const REPO_URL = 'https://github.com/strapi/LaunchPad.git'; const PM = 'yarn'; /** - * Which frontend to scaffold: the flag if given, otherwise a prompt. + * Which frontend to scaffold, chosen from the ones this clone actually has. * - * An unknown --framework is rejected rather than silently falling back, so a - * typo in a script fails loudly. + * `available` comes from the cloned tree rather than the registry, so the + * prompt never offers something the checked-out ref cannot provide. Offering + * a choice and then rejecting it is worse than not offering it. */ -async function resolveFramework(flag) { +async function resolveFramework(flag, available) { if (flag) { - const framework = getFramework(flag); - if (!framework) { + const framework = available.find((f) => f.name === flag); + if (framework) return framework; + + const known = getFramework(flag); + console.log(); + if (known) { + // A real frontend, just not in this ref. + log.error( + `This LaunchPad ref has no ${known.name}/ directory, so --framework ${known.name} cannot work.` + ); + console.log( + ` Available here: ${available.map((f) => f.name).join(', ')}` + ); + console.log( + ' Try a ref that includes it, e.g. --ref feat/tanstack-frontend' + ); + } else { log.error( `Unknown framework "${flag}". Expected one of: ${frameworkNames().join(', ')}` ); - process.exit(1); } - return framework; + console.log(); + process.exit(1); } + if (available.length === 1) return available[0]; + // Not a TTY (CI, piped input) — prompting would hang. - if (!process.stdin.isTTY) return getFramework(DEFAULT_FRAMEWORK); + if (!process.stdin.isTTY) { + return available.find((f) => f.name === DEFAULT_FRAMEWORK) ?? available[0]; + } const choice = await p.select({ message: 'Which frontend would you like to run?', - options: FRAMEWORKS.map((f) => ({ + options: available.map((f) => ({ value: f.name, label: f.label, hint: `port ${f.port}`, })), - initialValue: DEFAULT_FRAMEWORK, + initialValue: + available.find((f) => f.name === DEFAULT_FRAMEWORK)?.name ?? + available[0].name, }); if (p.isCancel(choice)) { @@ -58,33 +80,14 @@ async function resolveFramework(flag) { process.exit(0); } - return getFramework(choice); + return available.find((f) => f.name === choice); } -/** - * Fails early when the cloned ref predates the chosen frontend. - * - * Without this the run gets several steps further before `yarn dev:astro` - * fails with something far less obvious. - */ -function assertFrameworkPresent(targetDir, framework, ref) { - if (framework.name === DEFAULT_FRAMEWORK) return; - if (fs.existsSync(path.join(targetDir, framework.name))) return; - - const available = FRAMEWORKS.filter((f) => - fs.existsSync(path.join(targetDir, f.name)) - ).map((f) => f.name); - - console.log(); - log.error( - `This LaunchPad ref has no ${framework.name}/ directory, so --framework ${framework.name} cannot work.` - ); - console.log(` Available here: ${available.join(', ') || 'none'}`); - console.log( - ` Try a ref that includes it, e.g. --ref feat/tanstack-frontend` +/** The frontends present in a cloned LaunchPad tree. */ +function detectFrameworks(targetDir) { + return FRAMEWORKS.filter((f) => + fs.existsSync(path.join(targetDir, f.name, 'package.json')) ); - console.log(); - process.exit(1); } function printPlan(targetDir, framework, options, ref) { @@ -111,15 +114,16 @@ export async function createLaunchpadApp(directory, options) { const ref = options.ref; console.log(); - const framework = await resolveFramework(options.framework); + // A dry run has nothing to inspect, so it works from the registry and shows + // every frontend the CLI knows about. if (options.dryRun) { + const framework = await resolveFramework(options.framework, FRAMEWORKS); printPlan(targetDir, framework, options, ref); return; } log.info(`Creating LaunchPad app in ${targetDir}`); - log.info(`Frontend: ${chalk.bold(framework.label)}\n`); await checkPrerequisites(PM); console.log(); @@ -164,7 +168,19 @@ export async function createLaunchpadApp(directory, options) { process.exit(1); } - assertFrameworkPresent(targetDir, framework, ref); + // Ask only now: the choice is limited to what this ref actually contains, + // which the registry alone cannot tell us. + const available = detectFrameworks(targetDir); + if (available.length === 0) { + console.log(); + log.error('That ref has no recognizable frontend directory.'); + console.log(` Expected one of: ${frameworkNames().join(', ')}`); + console.log(); + process.exit(1); + } + + const framework = await resolveFramework(options.framework, available); + log.info(`Frontend: ${chalk.bold(framework.label)}`); // The clone's history is removed above, so give the user a repo of their own // rather than leaving the directory untracked. From 5a5a78cb0eceaf5198167cffe8ecff669a566533 Mon Sep 17 00:00:00 2001 From: Paul Bratslavsky Date: Tue, 8 Sep 2026 12:09:38 -0700 Subject: [PATCH 03/11] Always show the picker, and fetch the ref the choice needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three problems, all found by running it. The picker was skipped whenever the clone held a single frontend, which on LaunchPad's default branch is always. Running with no flags chose Next silently and never showed the menu. It now always lists all four. Choosing a frontend that is not on the default branch used to fail with an error telling you to pass --ref. The ref is now derived from the choice: astro, nuxt and tanstack fetch feat/tanstack-frontend and say so, next takes the default branch, and an explicit --ref still wins. `needsRef` in src/frameworks.js carries a note to delete it once the multi-frontend PR merges. The post-clone check stays as a safety net for a stale --ref or a renamed directory. The clone spinner sat motionless for the ~19 seconds LaunchPad's 54MB takes to fetch, which reads as a hang. It now counts elapsed seconds and says what it is waiting for. git's own --progress was tried first and reverted: it emits thousands of carriage-return updates that do not collapse when stdout is not a TTY, which is worse than no feedback at all. Adds --repo, taking a fork URL or a local path. git clones from a path, so testing needs neither the network nor a pushed branch; local sources are rewritten to file:// so --depth still applies. Verified by scaffolding from a local checkout — the clone becomes instant and only yarn install costs time. Checked all four end to end: each resolves to its own dev script and port, next/dev/3000 through tanstack/dev:tanstack/3002. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XZNfEU8h231jo1hu7kcviZ --- cli/bin/cli.js | 5 ++ cli/src/commands/create.js | 144 +++++++++++++++++++++++-------------- cli/src/frameworks.js | 15 ++++ 3 files changed, 112 insertions(+), 52 deletions(-) diff --git a/cli/bin/cli.js b/cli/bin/cli.js index b895e691..90a1ff08 100755 --- a/cli/bin/cli.js +++ b/cli/bin/cli.js @@ -24,6 +24,10 @@ program `frontend to run (${frameworkNames().join(', ')}) — prompts if omitted` ) .option('-r, --ref ', 'branch or tag of the LaunchPad repo to clone') + .option( + '--repo ', + 'clone from somewhere else — a fork, or a local checkout for testing' + ) .option('--no-seed', 'skip seeding demo data') .option('--no-start', 'skip starting dev servers after setup') .option('--no-git', 'skip initializing a git repository') @@ -39,6 +43,7 @@ Examples: $ create-launchpad-app my-app --framework astro $ create-launchpad-app my-app --framework nuxt --no-start $ create-launchpad-app my-app --dry-run + $ create-launchpad-app my-app --repo ../LaunchPad --framework nuxt All frontends share one Strapi backend on port ${1337}. ` diff --git a/cli/src/commands/create.js b/cli/src/commands/create.js index 5d34ab1a..1dd1b0e2 100644 --- a/cli/src/commands/create.js +++ b/cli/src/commands/create.js @@ -8,71 +8,73 @@ import ora from 'ora'; import { DEFAULT_FRAMEWORK, FRAMEWORKS, + MULTI_FRONTEND_REF, STRAPI_PORT, frameworkNames, getFramework, + needsRef, } from '../frameworks.js'; import { log } from '../utils/logger.js'; import { isPortAvailable } from '../utils/ports.js'; import { checkPrerequisites } from '../utils/prerequisites.js'; -const REPO_URL = 'https://github.com/strapi/LaunchPad.git'; +const DEFAULT_REPO = 'https://github.com/strapi/LaunchPad.git'; + +/** + * git treats a local path as a clone source, which is far quicker than going + * to GitHub and works offline. `--depth` is ignored for a plain path, so a + * local source is rewritten to a file:// URL to keep the clone shallow. + */ +function normalizeRepo(repo) { + if (!repo) return { url: DEFAULT_REPO, local: false }; + if (/^[a-z]+:\/\//i.test(repo) || repo.includes('@')) { + return { url: repo, local: false }; + } + const abs = path.resolve(process.cwd(), repo); + if (!fs.existsSync(path.join(abs, '.git'))) { + console.log(); + log.error(`No git repository at ${abs}`); + console.log(); + process.exit(1); + } + return { url: `file://${abs}`, local: true }; +} // LaunchPad pins yarn in its root package.json, so Corepack will refuse any // other package manager. There is no point offering a choice. const PM = 'yarn'; /** - * Which frontend to scaffold, chosen from the ones this clone actually has. + * Which frontend to scaffold. * - * `available` comes from the cloned tree rather than the registry, so the - * prompt never offers something the checked-out ref cannot provide. Offering - * a choice and then rejecting it is worse than not offering it. + * Always offers the full set. Frontends that are not on LaunchPad's default + * branch yet are still selectable — `resolveRef` fetches a ref that has them. */ -async function resolveFramework(flag, available) { +async function resolveFramework(flag) { if (flag) { - const framework = available.find((f) => f.name === flag); - if (framework) return framework; - - const known = getFramework(flag); - console.log(); - if (known) { - // A real frontend, just not in this ref. - log.error( - `This LaunchPad ref has no ${known.name}/ directory, so --framework ${known.name} cannot work.` - ); - console.log( - ` Available here: ${available.map((f) => f.name).join(', ')}` - ); - console.log( - ' Try a ref that includes it, e.g. --ref feat/tanstack-frontend' - ); - } else { + const framework = getFramework(flag); + if (!framework) { + console.log(); log.error( `Unknown framework "${flag}". Expected one of: ${frameworkNames().join(', ')}` ); + console.log(); + process.exit(1); } - console.log(); - process.exit(1); + return framework; } - if (available.length === 1) return available[0]; - // Not a TTY (CI, piped input) — prompting would hang. - if (!process.stdin.isTTY) { - return available.find((f) => f.name === DEFAULT_FRAMEWORK) ?? available[0]; - } + if (!process.stdin.isTTY) return getFramework(DEFAULT_FRAMEWORK); const choice = await p.select({ message: 'Which frontend would you like to run?', - options: available.map((f) => ({ + options: FRAMEWORKS.map((f) => ({ value: f.name, label: f.label, hint: `port ${f.port}`, })), - initialValue: - available.find((f) => f.name === DEFAULT_FRAMEWORK)?.name ?? - available[0].name, + initialValue: DEFAULT_FRAMEWORK, }); if (p.isCancel(choice)) { @@ -80,7 +82,19 @@ async function resolveFramework(flag, available) { process.exit(0); } - return available.find((f) => f.name === choice); + return getFramework(choice); +} + +/** + * The ref to clone: an explicit --ref wins, otherwise the branch that carries + * the chosen frontend. + */ +function resolveRef(framework, explicitRef) { + if (explicitRef) return { ref: explicitRef, automatic: false }; + if (needsRef(framework.name)) { + return { ref: MULTI_FRONTEND_REF, automatic: true }; + } + return { ref: undefined, automatic: false }; } /** The frontends present in a cloned LaunchPad tree. */ @@ -90,9 +104,9 @@ function detectFrameworks(targetDir) { ); } -function printPlan(targetDir, framework, options, ref) { +function printPlan(targetDir, framework, options, ref, automatic, repoUrl) { const steps = [ - `clone ${REPO_URL}${ref ? ` (ref ${ref})` : ''} into ${targetDir}`, + `clone ${repoUrl}${ref ? ` (ref ${ref}${automatic ? ', chosen automatically' : ''})` : ''} into ${targetDir}`, options.git ? 'git init' : 'skip git init (--no-git)', `${PM} install && ${PM} setup`, options.seed ? `${PM} seed` : 'skip seed (--no-seed)', @@ -111,19 +125,29 @@ function printPlan(targetDir, framework, options, ref) { export async function createLaunchpadApp(directory, options) { const targetDir = path.resolve(process.cwd(), directory); - const ref = options.ref; console.log(); - // A dry run has nothing to inspect, so it works from the registry and shows - // every frontend the CLI knows about. + const framework = await resolveFramework(options.framework); + const repo = normalizeRepo(options.repo); + // A local clone is whatever that checkout has; the automatic ref only makes + // sense for the canonical GitHub repo. + const { ref, automatic } = repo.local + ? { ref: options.ref, automatic: false } + : resolveRef(framework, options.ref); + if (options.dryRun) { - const framework = await resolveFramework(options.framework, FRAMEWORKS); - printPlan(targetDir, framework, options, ref); + printPlan(targetDir, framework, options, ref, automatic, repo.url); return; } log.info(`Creating LaunchPad app in ${targetDir}`); + log.info(`Frontend: ${chalk.bold(framework.label)}`); + if (automatic) { + log.info( + `Using branch ${chalk.bold(ref)} — ${framework.label} is not on LaunchPad's default branch yet.` + ); + } await checkPrerequisites(PM); console.log(); @@ -150,15 +174,27 @@ export async function createLaunchpadApp(directory, options) { process.exit(1); } - const cloneSpinner = ora('Cloning...').start(); + // LaunchPad is ~54MB, so this takes roughly 20 seconds over the network. + // A bare spinner for that long reads as a hang, so the elapsed time is + // shown. git's own --progress is not used: it emits thousands of + // carriage-return updates that do not collapse when stdout is not a TTY. + const started = Date.now(); + const cloneSpinner = ora('Cloning (about 20s, ~54MB)...').start(); + const tick = setInterval(() => { + const secs = Math.round((Date.now() - started) / 1000); + cloneSpinner.text = `Cloning (about 20s, ~54MB)... ${secs}s`; + }, 1000); + try { const args = ['clone', '--depth=1']; if (ref) args.push('--branch', ref); - args.push(REPO_URL, targetDir); + args.push(repo.url, targetDir); await execa('git', args); fs.rmSync(path.join(targetDir, '.git'), { recursive: true, force: true }); + clearInterval(tick); cloneSpinner.succeed(`Repository cloned${ref ? ` (${ref})` : ''}`); } catch (error) { + clearInterval(tick); cloneSpinner.fail('Failed to clone repository'); log.error( ref @@ -168,20 +204,24 @@ export async function createLaunchpadApp(directory, options) { process.exit(1); } - // Ask only now: the choice is limited to what this ref actually contains, - // which the registry alone cannot tell us. + // Safety net: the ref resolved above should always carry the chosen + // frontend, but a stale --ref or a renamed directory would slip through and + // fail much later on a dev script that does not exist. const available = detectFrameworks(targetDir); - if (available.length === 0) { + if (!available.some((f) => f.name === framework.name)) { console.log(); - log.error('That ref has no recognizable frontend directory.'); - console.log(` Expected one of: ${frameworkNames().join(', ')}`); + log.error( + `This ref has no ${framework.name}/ directory, so ${framework.label} cannot run.` + ); + console.log( + ` Available here: ${available.map((f) => f.name).join(', ') || 'none'}` + ); + if (options.ref) + console.log(' Try omitting --ref, or pick a ref that has it.'); console.log(); process.exit(1); } - const framework = await resolveFramework(options.framework, available); - log.info(`Frontend: ${chalk.bold(framework.label)}`); - // The clone's history is removed above, so give the user a repo of their own // rather than leaving the directory untracked. if (options.git) { diff --git a/cli/src/frameworks.js b/cli/src/frameworks.js index 6c7ea6d0..fa6cfea5 100644 --- a/cli/src/frameworks.js +++ b/cli/src/frameworks.js @@ -28,6 +28,21 @@ export const STRAPI_PORT = 1337; /** The frontend used when none is chosen. */ export const DEFAULT_FRAMEWORK = 'next'; +/** + * Temporary: astro, nuxt and tanstack are not on LaunchPad's default branch + * yet. Choosing one clones this ref instead, so the CLI can offer all four + * before the multi-frontend PR lands. + * + * Delete this and `needsRef` once that PR merges — `git log --oneline main` + * will show the four frontends and the post-clone check will cover the rest. + */ +export const MULTI_FRONTEND_REF = 'feat/tanstack-frontend'; + +const ON_DEFAULT_BRANCH = new Set(['next']); + +/** True when this frontend still needs MULTI_FRONTEND_REF to be available. */ +export const needsRef = (name) => !ON_DEFAULT_BRANCH.has(name); + export const frameworkNames = () => FRAMEWORKS.map((f) => f.name); export function getFramework(name) { From e4b299017aea124b3ef25b1b76d9deb61c612271 Mon Sep 17 00:00:00 2001 From: Paul Bratslavsky Date: Tue, 8 Sep 2026 12:34:00 -0700 Subject: [PATCH 04/11] Point the admin's Preview button at the chosen frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolding with --framework astro left CLIENT_URL at http://localhost:3000, so the Preview button in the Strapi admin opened Next while the site being worked on was Astro on 4321. `yarn setup` propagates the shared PREVIEW_SECRET to every frontend but leaves CLIENT_URL alone, because it cannot know which frontend you intend to run. `yarn use ` is the command that sets it, and the CLI knows the answer, so it now runs it after setup. PREVIEW_SECRET was already correct — verified identical across strapi and all four frontends. Only the URL was wrong. Verified per frontend: next 3000, astro 4321, nuxt 3001, tanstack 3002. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XZNfEU8h231jo1hu7kcviZ --- cli/src/commands/create.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cli/src/commands/create.js b/cli/src/commands/create.js index 1dd1b0e2..a4cfc5c5 100644 --- a/cli/src/commands/create.js +++ b/cli/src/commands/create.js @@ -246,6 +246,13 @@ export async function createLaunchpadApp(directory, options) { try { await execa(PM, ['install'], { cwd: targetDir }); await execa(PM, ['setup'], { cwd: targetDir }); + + // setup writes the shared PREVIEW_SECRET to every frontend, but leaves + // CLIENT_URL at its default of Next. Strapi reads CLIENT_URL to build the + // admin's Preview link, so without this the Preview button opens Next + // whatever frontend was chosen. + await execa(PM, ['use', framework.name], { cwd: targetDir }); + installSpinner.succeed('Dependencies installed'); } catch (error) { installSpinner.fail('Failed to install dependencies'); From cb3e532f4ff5336136c4606d4365e53e379cbd75 Mon Sep 17 00:00:00 2001 From: Paul Bratslavsky Date: Tue, 8 Sep 2026 13:04:32 -0700 Subject: [PATCH 05/11] Check ports before scaffolding, and name what holds them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The port check only ran when the CLI was about to start the servers, so scaffolding with --no-start said nothing about a conflict. It also did not work. `isPortAvailable` bound a socket to decide, with no host. Node then listens on IPv6 `::`, which on macOS does not collide with Strapi's IPv4 `0.0.0.0`, so an occupied port reported as free. Astro is worse: it binds `[::1]` specifically, and on BSD a wildcard bind coexists with a loopback one. No bind probe covers every combination, so it connects instead — if something accepts, the port is taken, whatever it bound to. Verified against a live Strapi and Astro, which both used to report free. The check now runs as soon as the frontend is known, before cloning, and reports which directory holds each port. Two LaunchPad checkouts is the case worth naming: the second one answers on the right port with its own PREVIEW_SECRET, so the admin's preview fails with "Invalid token" and nothing points at a port conflict. That is exactly how this was found. ⚠ Port 4321 (Astro) is already in use. held by: /Users/paul/Desktop/lp-cli-test/my-app/astro Before starting it stays a hard failure; up front it is a warning, since --no-start means nothing is about to bind anything. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XZNfEU8h231jo1hu7kcviZ --- cli/src/commands/create.js | 65 ++++++++++++++++++++++++------ cli/src/utils/ports.js | 81 +++++++++++++++++++++++++++++++++----- 2 files changed, 123 insertions(+), 23 deletions(-) diff --git a/cli/src/commands/create.js b/cli/src/commands/create.js index a4cfc5c5..7222d691 100644 --- a/cli/src/commands/create.js +++ b/cli/src/commands/create.js @@ -15,7 +15,7 @@ import { needsRef, } from '../frameworks.js'; import { log } from '../utils/logger.js'; -import { isPortAvailable } from '../utils/ports.js'; +import { isPortAvailable, portOwner } from '../utils/ports.js'; import { checkPrerequisites } from '../utils/prerequisites.js'; const DEFAULT_REPO = 'https://github.com/strapi/LaunchPad.git'; @@ -104,6 +104,35 @@ function detectFrameworks(targetDir) { ); } +/** + * Ports the chosen frontend needs, and who currently holds them. + * + * Reported before anything is cloned. A second LaunchPad checkout left + * running is the awkward case: it answers on the right port with its own + * PREVIEW_SECRET, so the admin's preview fails with "Invalid token" rather + * than anything that points at a port conflict. + */ +async function findBlockedPorts(framework) { + const wanted = [ + { port: STRAPI_PORT, label: 'Strapi' }, + { port: framework.port, label: framework.label }, + ]; + + const blocked = []; + for (const { port, label } of wanted) { + if (await isPortAvailable(port)) continue; + blocked.push({ port, label, owner: await portOwner(port) }); + } + return blocked; +} + +function reportBlockedPorts(blocked) { + for (const { port, label, owner } of blocked) { + log.warn(`Port ${port} (${label}) is already in use.`); + if (owner) console.log(` held by: ${owner}`); + } +} + function printPlan(targetDir, framework, options, ref, automatic, repoUrl) { const steps = [ `clone ${repoUrl}${ref ? ` (ref ${ref}${automatic ? ', chosen automatically' : ''})` : ''} into ${targetDir}`, @@ -143,6 +172,18 @@ export async function createLaunchpadApp(directory, options) { log.info(`Creating LaunchPad app in ${targetDir}`); log.info(`Frontend: ${chalk.bold(framework.label)}`); + + // Checked here rather than only before starting, so a conflict surfaces now + // instead of after a clone and an install — and so it is still reported when + // --no-start means nothing will be started at all. + const blockedUpFront = await findBlockedPorts(framework); + if (blockedUpFront.length > 0) { + console.log(); + reportBlockedPorts(blockedUpFront); + console.log( + ` another LaunchPad on that port has its own PREVIEW_SECRET, which makes preview fail with "Invalid token"` + ); + } if (automatic) { log.info( `Using branch ${chalk.bold(ref)} — ${framework.label} is not on LaunchPad's default branch yet.` @@ -286,25 +327,23 @@ export async function createLaunchpadApp(directory, options) { nextStep('Starting development servers...'); - const blocked = []; - if (!(await isPortAvailable(STRAPI_PORT))) { - blocked.push(`${STRAPI_PORT} (Strapi)`); - } - if (!(await isPortAvailable(framework.port))) { - blocked.push(`${framework.port} (${framework.label})`); - } + const blocked = await findBlockedPorts(framework); if (blocked.length > 0) { console.log(); - log.error(`Port ${blocked.join(' and ')} already in use.`); + reportBlockedPorts(blocked); console.log(); console.log( ' Either stop whatever is using them, or change the ports in:' ); - console.log(` ${directory}/strapi/.env → Strapi`); - console.log( - ` ${directory}/${framework.name}/.env${' '.repeat(Math.max(0, 12 - framework.name.length))} → ${framework.label}` - ); + const envPaths = [ + [`${directory}/strapi/.env`, 'Strapi'], + [`${directory}/${framework.name}/.env`, framework.label], + ]; + const width = Math.max(...envPaths.map(([envPath]) => envPath.length)); + for (const [envPath, label] of envPaths) { + console.log(` ${envPath.padEnd(width)} → ${label}`); + } console.log(); console.log(' Then start it yourself:'); console.log(` cd ${directory}`); diff --git a/cli/src/utils/ports.js b/cli/src/utils/ports.js index c24a354b..13141ec7 100644 --- a/cli/src/utils/ports.js +++ b/cli/src/utils/ports.js @@ -1,17 +1,78 @@ +import { execa } from 'execa'; import net from 'node:net'; /** - * Check if a port is available. - * Returns true if available, false if in use. + * Whether a port is free. + * + * This connects rather than binds. Binding looks obvious but is unreliable + * here: servers pick different addresses (Strapi takes IPv4 `0.0.0.0`, Astro + * takes IPv6 `[::1]`), and on BSD a wildcard bind happily coexists with a + * loopback one — so a bind probe reported a busy port as free. If something + * accepts a connection, the port is taken, whatever it bound to. */ -export function isPortAvailable(port) { +function canConnect(port, host, timeout = 400) { return new Promise((resolve) => { - const server = net.createServer(); - server.unref(); // Prevent server from keeping the process alive - server.once('error', () => resolve(false)); - server.once('listening', () => { - server.close(() => resolve(true)); - }); - server.listen(port); + const socket = new net.Socket(); + const done = (result) => { + socket.destroy(); + resolve(result); + }; + socket.setTimeout(timeout); + socket.once('connect', () => done(true)); + socket.once('timeout', () => done(false)); + socket.once('error', () => done(false)); + socket.connect(port, host); }); } + +export async function isPortAvailable(port) { + const reachable = await Promise.all([ + canConnect(port, '127.0.0.1'), + canConnect(port, '::1'), + ]); + return !reachable.some(Boolean); +} + +/** + * The working directory of whatever is listening on a port, if it can be + * determined. + * + * Knowing a port is busy is much less useful than knowing which project owns + * it. Two LaunchPad checkouts each have their own PREVIEW_SECRET, so a + * frontend left running from a different one answers the admin's preview + * request and fails it with "Invalid token" — a confusing way to discover a + * port conflict. + * + * Best effort: lsof is not available everywhere, and the answer is only a + * hint, so any failure returns null rather than interrupting the run. + */ +export async function portOwner(port) { + try { + // -sTCP:LISTEN matters: without it lsof also returns clients *connected* + // to the port, and a browser tab's connection would be reported as the + // owner. + const { stdout: pids } = await execa('lsof', [ + '-nP', + '-tiTCP:' + port, + '-sTCP:LISTEN', + ]); + const pid = pids.split('\n')[0]?.trim(); + if (!pid) return null; + + const { stdout } = await execa('lsof', [ + '-a', + '-p', + pid, + '-d', + 'cwd', + '-Fn', + ]); + const cwd = stdout + .split('\n') + .find((line) => line.startsWith('n')) + ?.slice(1); + return cwd ?? null; + } catch { + return null; + } +} From 8224fe249620e135eee591a83329e0259b0ede01 Mon Sep 17 00:00:00 2001 From: Paul Bratslavsky Date: Wed, 9 Sep 2026 08:38:20 -0700 Subject: [PATCH 06/11] Drop the temporary branch routing now that #150 has merged astro, nuxt and tanstack are on the default branch, so the CLI no longer needs to send those choices to feat/tanstack-frontend. Removes MULTI_FRONTEND_REF, needsRef and resolveRef, along with the notice explaining which branch was being used and why. --ref stays, for forks and for pinning a tag. Verified against a plain clone of main with no flags: all four frontends present, CLIENT_URL on the chosen frontend's port, PREVIEW_SECRET matching between Strapi and that frontend. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GQSJu9Etpa6qjCny1CPWcc --- cli/src/commands/create.js | 35 ++++++----------------------------- cli/src/frameworks.js | 15 --------------- 2 files changed, 6 insertions(+), 44 deletions(-) diff --git a/cli/src/commands/create.js b/cli/src/commands/create.js index 7222d691..3c395eab 100644 --- a/cli/src/commands/create.js +++ b/cli/src/commands/create.js @@ -8,11 +8,9 @@ import ora from 'ora'; import { DEFAULT_FRAMEWORK, FRAMEWORKS, - MULTI_FRONTEND_REF, STRAPI_PORT, frameworkNames, getFramework, - needsRef, } from '../frameworks.js'; import { log } from '../utils/logger.js'; import { isPortAvailable, portOwner } from '../utils/ports.js'; @@ -47,8 +45,8 @@ const PM = 'yarn'; /** * Which frontend to scaffold. * - * Always offers the full set. Frontends that are not on LaunchPad's default - * branch yet are still selectable — `resolveRef` fetches a ref that has them. + * All four live on LaunchPad's default branch, so every option works without + * a --ref. */ async function resolveFramework(flag) { if (flag) { @@ -85,18 +83,6 @@ async function resolveFramework(flag) { return getFramework(choice); } -/** - * The ref to clone: an explicit --ref wins, otherwise the branch that carries - * the chosen frontend. - */ -function resolveRef(framework, explicitRef) { - if (explicitRef) return { ref: explicitRef, automatic: false }; - if (needsRef(framework.name)) { - return { ref: MULTI_FRONTEND_REF, automatic: true }; - } - return { ref: undefined, automatic: false }; -} - /** The frontends present in a cloned LaunchPad tree. */ function detectFrameworks(targetDir) { return FRAMEWORKS.filter((f) => @@ -133,9 +119,9 @@ function reportBlockedPorts(blocked) { } } -function printPlan(targetDir, framework, options, ref, automatic, repoUrl) { +function printPlan(targetDir, framework, options, ref, repoUrl) { const steps = [ - `clone ${repoUrl}${ref ? ` (ref ${ref}${automatic ? ', chosen automatically' : ''})` : ''} into ${targetDir}`, + `clone ${repoUrl}${ref ? ` (ref ${ref})` : ''} into ${targetDir}`, options.git ? 'git init' : 'skip git init (--no-git)', `${PM} install && ${PM} setup`, options.seed ? `${PM} seed` : 'skip seed (--no-seed)', @@ -159,14 +145,10 @@ export async function createLaunchpadApp(directory, options) { const framework = await resolveFramework(options.framework); const repo = normalizeRepo(options.repo); - // A local clone is whatever that checkout has; the automatic ref only makes - // sense for the canonical GitHub repo. - const { ref, automatic } = repo.local - ? { ref: options.ref, automatic: false } - : resolveRef(framework, options.ref); + const ref = options.ref; if (options.dryRun) { - printPlan(targetDir, framework, options, ref, automatic, repo.url); + printPlan(targetDir, framework, options, ref, repo.url); return; } @@ -184,11 +166,6 @@ export async function createLaunchpadApp(directory, options) { ` another LaunchPad on that port has its own PREVIEW_SECRET, which makes preview fail with "Invalid token"` ); } - if (automatic) { - log.info( - `Using branch ${chalk.bold(ref)} — ${framework.label} is not on LaunchPad's default branch yet.` - ); - } await checkPrerequisites(PM); console.log(); diff --git a/cli/src/frameworks.js b/cli/src/frameworks.js index fa6cfea5..6c7ea6d0 100644 --- a/cli/src/frameworks.js +++ b/cli/src/frameworks.js @@ -28,21 +28,6 @@ export const STRAPI_PORT = 1337; /** The frontend used when none is chosen. */ export const DEFAULT_FRAMEWORK = 'next'; -/** - * Temporary: astro, nuxt and tanstack are not on LaunchPad's default branch - * yet. Choosing one clones this ref instead, so the CLI can offer all four - * before the multi-frontend PR lands. - * - * Delete this and `needsRef` once that PR merges — `git log --oneline main` - * will show the four frontends and the post-clone check will cover the rest. - */ -export const MULTI_FRONTEND_REF = 'feat/tanstack-frontend'; - -const ON_DEFAULT_BRANCH = new Set(['next']); - -/** True when this frontend still needs MULTI_FRONTEND_REF to be available. */ -export const needsRef = (name) => !ON_DEFAULT_BRANCH.has(name); - export const frameworkNames = () => FRAMEWORKS.map((f) => f.name); export function getFramework(name) { From 13037cf93f897df9dd5fca99724e382d18a9b661 Mon Sep 17 00:00:00 2001 From: Paul Bratslavsky Date: Wed, 9 Sep 2026 08:52:32 -0700 Subject: [PATCH 07/11] Rename the package to create-strapi-launchpad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create-launchpad-app is already published by someone else — an unrelated package, currently at 1.0.38. The README told people to run `npx create-launchpad-app`, which would have installed that instead of this. The name was never available to take. create-strapi-launchpad is free, and says whose LaunchPad it scaffolds. `npm create strapi-launchpad` works as the shorthand. The bin is renamed to match, along with the help examples, both READMEs and AGENTS.md. Verified the way a user gets it rather than through npm link: packed the tarball, installed it globally, ran it from an unrelated directory. Nine files ship, no tests or node_modules among them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GQSJu9Etpa6qjCny1CPWcc --- AGENTS.md | 2 +- README.md | 2 +- cli/README.md | 6 +++--- cli/bin/cli.js | 12 ++++++------ cli/package-lock.json | 6 +++--- cli/package.json | 4 ++-- cli/src/commands/create.js | 2 +- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 80f88db2..e7f8c339 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ LaunchPad is the official Strapi demo app. - `astro/`: Astro frontend, same content and routes. - `nuxt/`: Nuxt 4 frontend, same content and routes. - `tanstack/`: TanStack Start frontend, same content and routes. -- `cli/`: `create-launchpad-app`, the npm package that scaffolds this repo. Uses npm, not yarn, since it is published separately. +- `cli/`: `create-strapi-launchpad`, the npm package that scaffolds this repo. Uses npm, not yarn, since it is published separately. - Root: setup/dev/format scripts using Yarn 4.5.0. Each directory keeps its own lockfile; this is not a Yarn workspace. ## First Read diff --git a/README.md b/README.md index f50534d5..bd93fe13 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Strap yourself in! You can get started with this project on your local machine b ## Quickest start ```bash -npx create-launchpad-app my-app +npx create-strapi-launchpad my-app ``` It asks which frontend you want, then clones, installs, seeds and starts it. diff --git a/cli/README.md b/cli/README.md index da2ab91d..0f20a517 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,10 +1,10 @@ -# create-launchpad-app +# create-strapi-launchpad Scaffold [LaunchPad](https://github.com/strapi/LaunchPad), Strapi's official demo application, with the frontend you want to work in. ```bash -npx create-launchpad-app my-app +npx create-strapi-launchpad my-app ``` That clones LaunchPad, installs it, seeds the demo content, and starts the @@ -24,7 +24,7 @@ LaunchPad ships four frontends against one Strapi backend. Strapi runs on **1337** for all of them. ```bash -npx create-launchpad-app my-app --framework astro +npx create-strapi-launchpad my-app --framework astro ``` ## Options diff --git a/cli/bin/cli.js b/cli/bin/cli.js index 90a1ff08..228bf1a6 100755 --- a/cli/bin/cli.js +++ b/cli/bin/cli.js @@ -15,7 +15,7 @@ const frameworkList = FRAMEWORKS.map( ).join('\n'); program - .name('create-launchpad-app') + .name('create-strapi-launchpad') .description('Scaffold the official Strapi LaunchPad demo application') .version(version) .argument('[directory]', 'directory to create the project in', 'launchpad') @@ -39,11 +39,11 @@ Frontends: ${frameworkList} Examples: - $ create-launchpad-app my-app - $ create-launchpad-app my-app --framework astro - $ create-launchpad-app my-app --framework nuxt --no-start - $ create-launchpad-app my-app --dry-run - $ create-launchpad-app my-app --repo ../LaunchPad --framework nuxt + $ create-strapi-launchpad my-app + $ create-strapi-launchpad my-app --framework astro + $ create-strapi-launchpad my-app --framework nuxt --no-start + $ create-strapi-launchpad my-app --dry-run + $ create-strapi-launchpad my-app --repo ../LaunchPad --framework nuxt All frontends share one Strapi backend on port ${1337}. ` diff --git a/cli/package-lock.json b/cli/package-lock.json index a54878bb..1735dd79 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,11 +1,11 @@ { - "name": "create-launchpad-app", + "name": "create-strapi-launchpad", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "create-launchpad-app", + "name": "create-strapi-launchpad", "version": "1.0.0", "license": "MIT", "dependencies": { @@ -16,7 +16,7 @@ "ora": "^8.2.0" }, "bin": { - "create-launchpad-app": "bin/cli.js" + "create-strapi-launchpad": "bin/cli.js" }, "engines": { "node": ">=20.19.0" diff --git a/cli/package.json b/cli/package.json index cfaa92a5..7efc9f53 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,10 +1,10 @@ { - "name": "create-launchpad-app", + "name": "create-strapi-launchpad", "version": "1.0.0", "description": "CLI to scaffold the official Strapi LaunchPad demo application with the frontend of your choice", "type": "module", "bin": { - "create-launchpad-app": "./bin/cli.js" + "create-strapi-launchpad": "./bin/cli.js" }, "scripts": { "dev": "node ./bin/cli.js", diff --git a/cli/src/commands/create.js b/cli/src/commands/create.js index 3c395eab..2c3f5ee4 100644 --- a/cli/src/commands/create.js +++ b/cli/src/commands/create.js @@ -248,7 +248,7 @@ export async function createLaunchpadApp(directory, options) { await execa('git', ['add', '-A'], { cwd: targetDir }); await execa( 'git', - ['commit', '-q', '-m', 'Initial commit from create-launchpad-app'], + ['commit', '-q', '-m', 'Initial commit from create-strapi-launchpad'], { cwd: targetDir } ); log.success('Initialized a git repository'); From c8604ed271d96cb9f22b8dab8f280d0d029e9d92 Mon Sep 17 00:00:00 2001 From: Paul Bratslavsky Date: Wed, 9 Sep 2026 09:27:35 -0700 Subject: [PATCH 08/11] Start at 0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.0.0 claims a stable interface. The flags are a day old and have already changed twice — --package-manager was removed, and the picker was rebuilt after it turned out to be offering frontends the clone did not have. 0.x says that plainly and leaves room to keep changing them. npm versions cannot be reused once published, so this is worth getting right before the first publish rather than after. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GQSJu9Etpa6qjCny1CPWcc --- cli/package-lock.json | 4 ++-- cli/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 1735dd79..3cc6eab3 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "create-strapi-launchpad", - "version": "1.0.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "create-strapi-launchpad", - "version": "1.0.0", + "version": "0.1.0", "license": "MIT", "dependencies": { "@clack/prompts": "^1.8.0", diff --git a/cli/package.json b/cli/package.json index 7efc9f53..10fc733d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "create-strapi-launchpad", - "version": "1.0.0", + "version": "0.1.0", "description": "CLI to scaffold the official Strapi LaunchPad demo application with the frontend of your choice", "type": "module", "bin": { From 669eac82672a55e5b2a5a37eeb405305669cdb14 Mon Sep 17 00:00:00 2001 From: Paul Bratslavsky Date: Wed, 9 Sep 2026 09:56:33 -0700 Subject: [PATCH 09/11] Print the restart command, and correct the yarn-only reasoning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `yarn dev` is Next for every scaffold, so someone who chose Nuxt and typed the obvious command gets the wrong frontend. Rather than change what `dev` means — which would break it for existing contributors — the CLI now prints the frontend-specific command before the dev servers take over the terminal: To restart later: cd my-app && yarn dev:nuxt It goes before the servers start because their output scrolls past immediately, and this is what people need after the first Ctrl+C. The --no-start path already ended with it. Also corrects a claim repeated in four places: that Corepack refuses npm because the root package.json pins yarn@4.5.0. It does not. `npm install` and `npm run dev` both work here; `packageManager` constrains which yarn or pnpm Corepack shims in, not whether npm may run. The real reason the CLI drives yarn is duller and true: every directory ships a yarn.lock and its scripts shell out to yarn. Removing --package-manager was still right, but for the reason that was actually tested — `npm setup`, `npm seed` and `npm dev` are all "Unknown command", since npm accepts bare names only for its own built-ins. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GQSJu9Etpa6qjCny1CPWcc --- cli/README.md | 6 +++--- cli/src/commands/create.js | 14 ++++++++++++-- cli/src/utils/prerequisites.js | 9 +++++---- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/cli/README.md b/cli/README.md index 0f20a517..f4c860d2 100644 --- a/cli/README.md +++ b/cli/README.md @@ -43,9 +43,9 @@ npx create-strapi-launchpad my-app --framework astro - Node.js 20.19 or newer - Git -Yarn is required — LaunchPad pins `yarn@4.5.0` in its root `package.json`, so -npm and pnpm will not work. If yarn is missing, the CLI enables it through -Corepack for you. +Yarn is required. Every LaunchPad directory ships a `yarn.lock` and its +scripts call yarn directly, so that is what the CLI drives. If yarn is +missing, the CLI enables it through Corepack for you. ## What it does diff --git a/cli/src/commands/create.js b/cli/src/commands/create.js index 2c3f5ee4..18adf391 100644 --- a/cli/src/commands/create.js +++ b/cli/src/commands/create.js @@ -38,8 +38,10 @@ function normalizeRepo(repo) { return { url: `file://${abs}`, local: true }; } -// LaunchPad pins yarn in its root package.json, so Corepack will refuse any -// other package manager. There is no point offering a choice. +// Every LaunchPad directory ships a yarn.lock and its scripts shell out to +// yarn, so that is what the CLI drives. npm technically runs here, but it +// would resolve against no lockfile and write a package-lock beside the +// yarn one. const PM = 'yarn'; /** @@ -333,6 +335,14 @@ export async function createLaunchpadApp(directory, options) { log.info(`Strapi admin → http://localhost:${STRAPI_PORT}/admin`); log.info(`${framework.label} → http://localhost:${framework.port}`); console.log(); + // Printed before the dev servers take the terminal over, because their + // output scrolls past immediately and this is what people need after the + // first Ctrl+C. `yarn dev` is Next for every scaffold, so naming the + // frontend-specific script matters. + console.log( + ` To restart later: cd ${directory} && ${PM} ${framework.devScript}` + ); + console.log(); try { await execa(PM, [framework.devScript], { diff --git a/cli/src/utils/prerequisites.js b/cli/src/utils/prerequisites.js index b0d0095e..1dedbeed 100644 --- a/cli/src/utils/prerequisites.js +++ b/cli/src/utils/prerequisites.js @@ -37,9 +37,9 @@ export async function checkPrerequisites() { process.exit(1); } - // LaunchPad pins yarn@4.5.0 in its root package.json, so this is the only - // package manager that will work. Corepack ships with Node and can provide - // it without a global install. + // Every LaunchPad directory ships a yarn.lock and its scripts shell out to + // yarn, so that is what the CLI drives. Corepack comes with Node and can + // provide yarn without a global install. try { await execaCommand('yarn --version'); log.success('Yarn available'); @@ -54,7 +54,8 @@ export async function checkPrerequisites() { ' corepack enable\n' + ' — or —\n' + ' npm install -g yarn\n\n' + - 'LaunchPad pins yarn@4.5.0, so npm and pnpm will not work.' + 'LaunchPad is set up for yarn: every directory ships a yarn.lock\n' + + 'and its scripts call yarn directly.' ); process.exit(1); } From 6e5779c8655557b1f9e4e5590b23c85a2e0341bc Mon Sep 17 00:00:00 2001 From: Paul Bratslavsky Date: Wed, 9 Sep 2026 10:21:25 -0700 Subject: [PATCH 10/11] Print the restart command when the dev servers stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command is already shown before the servers start, but by then it is long gone: a real run buries it under about 280 lines of server output. The moment it is wanted is right after Ctrl+C, so it now has the last word. ℹ Stopped. Restart: cd q2 && yarn dev:astro Other frontends: yarn dev (Next.js), yarn dev:nuxt, yarn dev:tanstack `yarn dev` is named as Next explicitly. It is the command people reach for, and in a scaffold built for another frontend it quietly starts the wrong one — better said out loud than discovered. Needs an explicit SIGINT handler. Ctrl+C signals the whole process group, so Node tears the process down before anything after the await runs. The first version relied on that and printed nothing, which only showed up by sending a real SIGINT rather than reading the code. SIGTERM is handled the same way, and a flag keeps it to once if both the signal and the await fire. `yarn dev` in the repo and in every scaffold is untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GQSJu9Etpa6qjCny1CPWcc --- cli/src/commands/create.js | 51 +++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/create.js b/cli/src/commands/create.js index 18adf391..6ad16b0c 100644 --- a/cli/src/commands/create.js +++ b/cli/src/commands/create.js @@ -344,12 +344,61 @@ export async function createLaunchpadApp(directory, options) { ); console.log(); + // Ctrl+C sends SIGINT to the whole process group, so Node tears this process + // down before anything after the await can run. Handling it explicitly is + // the only way to get the last word — without this the message never prints, + // which is exactly what happened the first time round. + let printed = false; + const sayGoodbye = () => { + if (printed) return; + printed = true; + printRestartHelp(directory, framework); + }; + process.on('SIGINT', () => { + sayGoodbye(); + process.exit(0); + }); + process.on('SIGTERM', () => { + sayGoodbye(); + process.exit(0); + }); + try { await execa(PM, [framework.devScript], { cwd: targetDir, stdio: 'inherit', }); } catch { - // Ctrl+C is the normal way out of a dev server. + // The dev servers exiting non-zero is normal on shutdown. } + + // Covers the servers stopping on their own rather than by Ctrl+C. + sayGoodbye(); +} + +/** + * Printed when the dev servers stop. + * + * The same command is shown before they start, but by then it is hundreds of + * lines up — a real run buries it under about 280 lines of server output. The + * moment someone actually needs it is right after Ctrl+C, so this puts it on + * the last line of the terminal. + * + * `yarn dev` is named explicitly as Next. It is the command people reach for, + * and in a scaffold built for another frontend it silently starts the wrong + * one — worth saying out loud rather than letting them discover it. + */ +function printRestartHelp(directory, framework) { + const others = FRAMEWORKS.filter((f) => f.name !== framework.name).map((f) => + f.devScript === 'dev' ? `${PM} dev (${f.label})` : `${PM} ${f.devScript}` + ); + + console.log(); + log.info('Stopped.'); + console.log(); + console.log( + ` Restart: cd ${directory} && ${PM} ${framework.devScript}` + ); + console.log(` Other frontends: ${others.join(', ')}`); + console.log(); } From ce9f42b833caaa7c84cc1c0a7c997059faea174e Mon Sep 17 00:00:00 2001 From: Paul Bratslavsky Date: Wed, 9 Sep 2026 10:24:20 -0700 Subject: [PATCH 11/11] Release 0.1.1 Two user-facing changes since 0.1.0: - The restart command is printed when the dev servers stop, not only before they start, where about 280 lines of server output bury it. - Corrects the claim that Corepack refuses npm here. It does not; the CLI drives yarn because every LaunchPad directory ships a yarn.lock and its scripts call yarn. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GQSJu9Etpa6qjCny1CPWcc --- cli/package-lock.json | 4 ++-- cli/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 3cc6eab3..3f5c615c 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "create-strapi-launchpad", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "create-strapi-launchpad", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { "@clack/prompts": "^1.8.0", diff --git a/cli/package.json b/cli/package.json index 10fc733d..f231addb 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "create-strapi-launchpad", - "version": "0.1.0", + "version": "0.1.1", "description": "CLI to scaffold the official Strapi LaunchPad demo application with the frontend of your choice", "type": "module", "bin": {