diff --git a/AGENTS.md b/AGENTS.md index f8e1b050..e7f8c339 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-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 @@ -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..bd93fe13 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-strapi-launchpad 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..f4c860d2 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,82 @@ +# 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-strapi-launchpad 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-strapi-launchpad 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. 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 + +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..228bf1a6 --- /dev/null +++ b/cli/bin/cli.js @@ -0,0 +1,60 @@ +#!/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-strapi-launchpad') + .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( + '--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') + .option('--dry-run', 'print what would happen without doing it') + .addHelpText( + 'after', + ` +Frontends: +${frameworkList} + +Examples: + $ 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}. +` + ) + .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..3f5c615c --- /dev/null +++ b/cli/package-lock.json @@ -0,0 +1,600 @@ +{ + "name": "create-strapi-launchpad", + "version": "0.1.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "create-strapi-launchpad", + "version": "0.1.1", + "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-strapi-launchpad": "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..f231addb --- /dev/null +++ b/cli/package.json @@ -0,0 +1,42 @@ +{ + "name": "create-strapi-launchpad", + "version": "0.1.1", + "description": "CLI to scaffold the official Strapi LaunchPad demo application with the frontend of your choice", + "type": "module", + "bin": { + "create-strapi-launchpad": "./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..6ad16b0c --- /dev/null +++ b/cli/src/commands/create.js @@ -0,0 +1,404 @@ +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, portOwner } from '../utils/ports.js'; +import { checkPrerequisites } from '../utils/prerequisites.js'; + +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 }; +} + +// 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'; + +/** + * Which frontend to scaffold. + * + * All four live on LaunchPad's default branch, so every option works without + * a --ref. + */ +async function resolveFramework(flag) { + if (flag) { + const framework = getFramework(flag); + if (!framework) { + console.log(); + log.error( + `Unknown framework "${flag}". Expected one of: ${frameworkNames().join(', ')}` + ); + console.log(); + 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); +} + +/** The frontends present in a cloned LaunchPad tree. */ +function detectFrameworks(targetDir) { + return FRAMEWORKS.filter((f) => + fs.existsSync(path.join(targetDir, f.name, 'package.json')) + ); +} + +/** + * 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, repoUrl) { + const steps = [ + `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)', + 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); + + console.log(); + + const framework = await resolveFramework(options.framework); + const repo = normalizeRepo(options.repo); + const ref = options.ref; + + if (options.dryRun) { + printPlan(targetDir, framework, options, ref, repo.url); + return; + } + + 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"` + ); + } + + 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); + } + + // 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); + 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 + ? `Could not clone ref "${ref}". Check the branch or tag exists.` + : error.message + ); + process.exit(1); + } + + // 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.some((f) => f.name === framework.name)) { + console.log(); + 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); + } + + // 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-strapi-launchpad'], + { 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 }); + + // 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'); + 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 = await findBlockedPorts(framework); + + if (blocked.length > 0) { + console.log(); + reportBlockedPorts(blocked); + console.log(); + console.log( + ' Either stop whatever is using them, or change the ports in:' + ); + 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}`); + 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(); + // 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(); + + // 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 { + // 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(); +} 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..13141ec7 --- /dev/null +++ b/cli/src/utils/ports.js @@ -0,0 +1,78 @@ +import { execa } from 'execa'; +import net from 'node:net'; + +/** + * 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. + */ +function canConnect(port, host, timeout = 400) { + return new Promise((resolve) => { + 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; + } +} diff --git a/cli/src/utils/prerequisites.js b/cli/src/utils/prerequisites.js new file mode 100644 index 00000000..1dedbeed --- /dev/null +++ b/cli/src/utils/prerequisites.js @@ -0,0 +1,63 @@ +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); + } + + // 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'); + } 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 is set up for yarn: every directory ships a yarn.lock\n' + + 'and its scripts call yarn directly.' + ); + 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",