create-strapi-launchpad 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/bin/cli.js +60 -0
- package/package.json +42 -0
- package/src/commands/create.js +345 -0
- package/src/frameworks.js +35 -0
- package/src/utils/logger.js +10 -0
- package/src/utils/ports.js +78 -0
- package/src/utils/prerequisites.js +62 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paul Bratslavsky
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# create-strapi-launchpad
|
|
2
|
+
|
|
3
|
+
Scaffold [LaunchPad](https://github.com/strapi/LaunchPad), Strapi's official
|
|
4
|
+
demo application, with the frontend you want to work in.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx create-strapi-launchpad my-app
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
That clones LaunchPad, installs it, seeds the demo content, and starts the
|
|
11
|
+
dev servers. With no `--framework` it asks which frontend to run.
|
|
12
|
+
|
|
13
|
+
## Frontends
|
|
14
|
+
|
|
15
|
+
LaunchPad ships four frontends against one Strapi backend.
|
|
16
|
+
|
|
17
|
+
| Frontend | Flag | Port |
|
|
18
|
+
| -------------- | ---------------------------- | ---- |
|
|
19
|
+
| Next.js | `--framework next` (default) | 3000 |
|
|
20
|
+
| Nuxt 4 | `--framework nuxt` | 3001 |
|
|
21
|
+
| TanStack Start | `--framework tanstack` | 3002 |
|
|
22
|
+
| Astro | `--framework astro` | 4321 |
|
|
23
|
+
|
|
24
|
+
Strapi runs on **1337** for all of them.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx create-strapi-launchpad my-app --framework astro
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Options
|
|
31
|
+
|
|
32
|
+
| Option | Description |
|
|
33
|
+
| ------------------------ | ---------------------------------------------------- |
|
|
34
|
+
| `-f, --framework <name>` | Frontend to run. Prompts if omitted. |
|
|
35
|
+
| `-r, --ref <ref>` | Branch or tag of the LaunchPad repo to clone. |
|
|
36
|
+
| `--no-seed` | Skip seeding the demo content. |
|
|
37
|
+
| `--no-start` | Set everything up, but do not start the dev servers. |
|
|
38
|
+
| `--no-git` | Do not initialize a git repository. |
|
|
39
|
+
| `--dry-run` | Print what would happen and exit. |
|
|
40
|
+
|
|
41
|
+
## Requirements
|
|
42
|
+
|
|
43
|
+
- Node.js 20.19 or newer
|
|
44
|
+
- Git
|
|
45
|
+
|
|
46
|
+
Yarn is required — LaunchPad pins `yarn@4.5.0` in its root `package.json`, so
|
|
47
|
+
npm and pnpm will not work. If yarn is missing, the CLI enables it through
|
|
48
|
+
Corepack for you.
|
|
49
|
+
|
|
50
|
+
## What it does
|
|
51
|
+
|
|
52
|
+
1. Clones LaunchPad (shallow) and removes its history
|
|
53
|
+
2. Initializes a fresh git repository with one commit
|
|
54
|
+
3. `yarn install && yarn setup` — installs every workspace and writes `.env` files
|
|
55
|
+
4. `yarn seed` — imports the demo content into SQLite
|
|
56
|
+
5. `yarn dev` (or `dev:astro`, `dev:nuxt`, `dev:tanstack`)
|
|
57
|
+
|
|
58
|
+
Use `--dry-run` to see the plan for any combination of flags without touching
|
|
59
|
+
the disk.
|
|
60
|
+
|
|
61
|
+
## Development
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm install
|
|
65
|
+
npm test
|
|
66
|
+
node ./bin/cli.js my-app --dry-run
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Frontends are declared in one place, [`src/frameworks.js`](src/frameworks.js).
|
|
70
|
+
|
|
71
|
+
It has to be a separate copy, because the CLI runs before the repo is cloned.
|
|
72
|
+
That copy can drift, and when it does the CLI checks the wrong port or calls a
|
|
73
|
+
dev script that does not exist. `test/registry-matches-launchpad.test.js`
|
|
74
|
+
reads the real sources — `../scripts/frontends.mts`, the root `package.json`
|
|
75
|
+
scripts and the frontend directories — and fails if they disagree.
|
|
76
|
+
|
|
77
|
+
That test is why this package lives in the LaunchPad repo rather than its own.
|
|
78
|
+
Run it from the repo root with `yarn test:cli`.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { program } from 'commander';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
|
|
5
|
+
import { createLaunchpadApp } from '../src/commands/create.js';
|
|
6
|
+
import { FRAMEWORKS, frameworkNames } from '../src/frameworks.js';
|
|
7
|
+
|
|
8
|
+
// Read the version from package.json rather than repeating it here, so the
|
|
9
|
+
// two cannot drift.
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const { version } = require('../package.json');
|
|
12
|
+
|
|
13
|
+
const frameworkList = FRAMEWORKS.map(
|
|
14
|
+
(f) => ` ${f.name.padEnd(9)} ${f.label} (port ${f.port})`
|
|
15
|
+
).join('\n');
|
|
16
|
+
|
|
17
|
+
program
|
|
18
|
+
.name('create-strapi-launchpad')
|
|
19
|
+
.description('Scaffold the official Strapi LaunchPad demo application')
|
|
20
|
+
.version(version)
|
|
21
|
+
.argument('[directory]', 'directory to create the project in', 'launchpad')
|
|
22
|
+
.option(
|
|
23
|
+
'-f, --framework <name>',
|
|
24
|
+
`frontend to run (${frameworkNames().join(', ')}) — prompts if omitted`
|
|
25
|
+
)
|
|
26
|
+
.option('-r, --ref <ref>', 'branch or tag of the LaunchPad repo to clone')
|
|
27
|
+
.option(
|
|
28
|
+
'--repo <url|path>',
|
|
29
|
+
'clone from somewhere else — a fork, or a local checkout for testing'
|
|
30
|
+
)
|
|
31
|
+
.option('--no-seed', 'skip seeding demo data')
|
|
32
|
+
.option('--no-start', 'skip starting dev servers after setup')
|
|
33
|
+
.option('--no-git', 'skip initializing a git repository')
|
|
34
|
+
.option('--dry-run', 'print what would happen without doing it')
|
|
35
|
+
.addHelpText(
|
|
36
|
+
'after',
|
|
37
|
+
`
|
|
38
|
+
Frontends:
|
|
39
|
+
${frameworkList}
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
$ create-strapi-launchpad my-app
|
|
43
|
+
$ create-strapi-launchpad my-app --framework astro
|
|
44
|
+
$ create-strapi-launchpad my-app --framework nuxt --no-start
|
|
45
|
+
$ create-strapi-launchpad my-app --dry-run
|
|
46
|
+
$ create-strapi-launchpad my-app --repo ../LaunchPad --framework nuxt
|
|
47
|
+
|
|
48
|
+
All frontends share one Strapi backend on port ${1337}.
|
|
49
|
+
`
|
|
50
|
+
)
|
|
51
|
+
.action(async (directory, options) => {
|
|
52
|
+
try {
|
|
53
|
+
await createLaunchpadApp(directory, options);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error(error?.message ?? error);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-strapi-launchpad",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI to scaffold the official Strapi LaunchPad demo application with the frontend of your choice",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-strapi-launchpad": "./bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "node ./bin/cli.js",
|
|
11
|
+
"test": "node --test \"test/**/*.test.js\""
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"strapi",
|
|
15
|
+
"launchpad",
|
|
16
|
+
"demo",
|
|
17
|
+
"cli",
|
|
18
|
+
"scaffold",
|
|
19
|
+
"astro",
|
|
20
|
+
"nuxt",
|
|
21
|
+
"tanstack",
|
|
22
|
+
"nextjs"
|
|
23
|
+
],
|
|
24
|
+
"author": "",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@clack/prompts": "^1.8.0",
|
|
28
|
+
"chalk": "^5.4.1",
|
|
29
|
+
"commander": "^13.1.0",
|
|
30
|
+
"execa": "^9.5.2",
|
|
31
|
+
"ora": "^8.2.0"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20.19.0"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"bin",
|
|
38
|
+
"src",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { execa } from 'execa';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import ora from 'ora';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_FRAMEWORK,
|
|
10
|
+
FRAMEWORKS,
|
|
11
|
+
STRAPI_PORT,
|
|
12
|
+
frameworkNames,
|
|
13
|
+
getFramework,
|
|
14
|
+
} from '../frameworks.js';
|
|
15
|
+
import { log } from '../utils/logger.js';
|
|
16
|
+
import { isPortAvailable, portOwner } from '../utils/ports.js';
|
|
17
|
+
import { checkPrerequisites } from '../utils/prerequisites.js';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_REPO = 'https://github.com/strapi/LaunchPad.git';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* git treats a local path as a clone source, which is far quicker than going
|
|
23
|
+
* to GitHub and works offline. `--depth` is ignored for a plain path, so a
|
|
24
|
+
* local source is rewritten to a file:// URL to keep the clone shallow.
|
|
25
|
+
*/
|
|
26
|
+
function normalizeRepo(repo) {
|
|
27
|
+
if (!repo) return { url: DEFAULT_REPO, local: false };
|
|
28
|
+
if (/^[a-z]+:\/\//i.test(repo) || repo.includes('@')) {
|
|
29
|
+
return { url: repo, local: false };
|
|
30
|
+
}
|
|
31
|
+
const abs = path.resolve(process.cwd(), repo);
|
|
32
|
+
if (!fs.existsSync(path.join(abs, '.git'))) {
|
|
33
|
+
console.log();
|
|
34
|
+
log.error(`No git repository at ${abs}`);
|
|
35
|
+
console.log();
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
return { url: `file://${abs}`, local: true };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// LaunchPad pins yarn in its root package.json, so Corepack will refuse any
|
|
42
|
+
// other package manager. There is no point offering a choice.
|
|
43
|
+
const PM = 'yarn';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Which frontend to scaffold.
|
|
47
|
+
*
|
|
48
|
+
* All four live on LaunchPad's default branch, so every option works without
|
|
49
|
+
* a --ref.
|
|
50
|
+
*/
|
|
51
|
+
async function resolveFramework(flag) {
|
|
52
|
+
if (flag) {
|
|
53
|
+
const framework = getFramework(flag);
|
|
54
|
+
if (!framework) {
|
|
55
|
+
console.log();
|
|
56
|
+
log.error(
|
|
57
|
+
`Unknown framework "${flag}". Expected one of: ${frameworkNames().join(', ')}`
|
|
58
|
+
);
|
|
59
|
+
console.log();
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
return framework;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Not a TTY (CI, piped input) — prompting would hang.
|
|
66
|
+
if (!process.stdin.isTTY) return getFramework(DEFAULT_FRAMEWORK);
|
|
67
|
+
|
|
68
|
+
const choice = await p.select({
|
|
69
|
+
message: 'Which frontend would you like to run?',
|
|
70
|
+
options: FRAMEWORKS.map((f) => ({
|
|
71
|
+
value: f.name,
|
|
72
|
+
label: f.label,
|
|
73
|
+
hint: `port ${f.port}`,
|
|
74
|
+
})),
|
|
75
|
+
initialValue: DEFAULT_FRAMEWORK,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (p.isCancel(choice)) {
|
|
79
|
+
p.cancel('Cancelled.');
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return getFramework(choice);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The frontends present in a cloned LaunchPad tree. */
|
|
87
|
+
function detectFrameworks(targetDir) {
|
|
88
|
+
return FRAMEWORKS.filter((f) =>
|
|
89
|
+
fs.existsSync(path.join(targetDir, f.name, 'package.json'))
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Ports the chosen frontend needs, and who currently holds them.
|
|
95
|
+
*
|
|
96
|
+
* Reported before anything is cloned. A second LaunchPad checkout left
|
|
97
|
+
* running is the awkward case: it answers on the right port with its own
|
|
98
|
+
* PREVIEW_SECRET, so the admin's preview fails with "Invalid token" rather
|
|
99
|
+
* than anything that points at a port conflict.
|
|
100
|
+
*/
|
|
101
|
+
async function findBlockedPorts(framework) {
|
|
102
|
+
const wanted = [
|
|
103
|
+
{ port: STRAPI_PORT, label: 'Strapi' },
|
|
104
|
+
{ port: framework.port, label: framework.label },
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
const blocked = [];
|
|
108
|
+
for (const { port, label } of wanted) {
|
|
109
|
+
if (await isPortAvailable(port)) continue;
|
|
110
|
+
blocked.push({ port, label, owner: await portOwner(port) });
|
|
111
|
+
}
|
|
112
|
+
return blocked;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function reportBlockedPorts(blocked) {
|
|
116
|
+
for (const { port, label, owner } of blocked) {
|
|
117
|
+
log.warn(`Port ${port} (${label}) is already in use.`);
|
|
118
|
+
if (owner) console.log(` held by: ${owner}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function printPlan(targetDir, framework, options, ref, repoUrl) {
|
|
123
|
+
const steps = [
|
|
124
|
+
`clone ${repoUrl}${ref ? ` (ref ${ref})` : ''} into ${targetDir}`,
|
|
125
|
+
options.git ? 'git init' : 'skip git init (--no-git)',
|
|
126
|
+
`${PM} install && ${PM} setup`,
|
|
127
|
+
options.seed ? `${PM} seed` : 'skip seed (--no-seed)',
|
|
128
|
+
options.start
|
|
129
|
+
? `${PM} ${framework.devScript} → Strapi :${STRAPI_PORT}, ${framework.label} :${framework.port}`
|
|
130
|
+
: 'skip dev start (--no-start)',
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
console.log();
|
|
134
|
+
log.info(`Plan for ${chalk.bold(framework.label)}:`);
|
|
135
|
+
steps.forEach((s, i) => console.log(` ${chalk.cyan(`${i + 1}.`)} ${s}`));
|
|
136
|
+
console.log();
|
|
137
|
+
log.info('Dry run — nothing was changed.');
|
|
138
|
+
console.log();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function createLaunchpadApp(directory, options) {
|
|
142
|
+
const targetDir = path.resolve(process.cwd(), directory);
|
|
143
|
+
|
|
144
|
+
console.log();
|
|
145
|
+
|
|
146
|
+
const framework = await resolveFramework(options.framework);
|
|
147
|
+
const repo = normalizeRepo(options.repo);
|
|
148
|
+
const ref = options.ref;
|
|
149
|
+
|
|
150
|
+
if (options.dryRun) {
|
|
151
|
+
printPlan(targetDir, framework, options, ref, repo.url);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
log.info(`Creating LaunchPad app in ${targetDir}`);
|
|
156
|
+
log.info(`Frontend: ${chalk.bold(framework.label)}`);
|
|
157
|
+
|
|
158
|
+
// Checked here rather than only before starting, so a conflict surfaces now
|
|
159
|
+
// instead of after a clone and an install — and so it is still reported when
|
|
160
|
+
// --no-start means nothing will be started at all.
|
|
161
|
+
const blockedUpFront = await findBlockedPorts(framework);
|
|
162
|
+
if (blockedUpFront.length > 0) {
|
|
163
|
+
console.log();
|
|
164
|
+
reportBlockedPorts(blockedUpFront);
|
|
165
|
+
console.log(
|
|
166
|
+
` another LaunchPad on that port has its own PREVIEW_SECRET, which makes preview fail with "Invalid token"`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await checkPrerequisites(PM);
|
|
171
|
+
console.log();
|
|
172
|
+
|
|
173
|
+
// Steps vary with the flags, so count them rather than hardcoding a total
|
|
174
|
+
// that drifts every time one becomes conditional.
|
|
175
|
+
const stepLabels = [
|
|
176
|
+
'Cloning LaunchPad repository',
|
|
177
|
+
'Installing dependencies',
|
|
178
|
+
options.seed && 'Seeding demo data',
|
|
179
|
+
options.start ? 'Starting development servers' : 'Finishing up',
|
|
180
|
+
].filter(Boolean);
|
|
181
|
+
const total = stepLabels.length;
|
|
182
|
+
let step = 0;
|
|
183
|
+
const nextStep = (msg) => log.step(++step, total, msg);
|
|
184
|
+
|
|
185
|
+
// --- Clone ---
|
|
186
|
+
nextStep('Cloning LaunchPad repository...');
|
|
187
|
+
|
|
188
|
+
if (fs.existsSync(targetDir)) {
|
|
189
|
+
log.error(
|
|
190
|
+
`Directory "${directory}" already exists. Pick a different name.`
|
|
191
|
+
);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// LaunchPad is ~54MB, so this takes roughly 20 seconds over the network.
|
|
196
|
+
// A bare spinner for that long reads as a hang, so the elapsed time is
|
|
197
|
+
// shown. git's own --progress is not used: it emits thousands of
|
|
198
|
+
// carriage-return updates that do not collapse when stdout is not a TTY.
|
|
199
|
+
const started = Date.now();
|
|
200
|
+
const cloneSpinner = ora('Cloning (about 20s, ~54MB)...').start();
|
|
201
|
+
const tick = setInterval(() => {
|
|
202
|
+
const secs = Math.round((Date.now() - started) / 1000);
|
|
203
|
+
cloneSpinner.text = `Cloning (about 20s, ~54MB)... ${secs}s`;
|
|
204
|
+
}, 1000);
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const args = ['clone', '--depth=1'];
|
|
208
|
+
if (ref) args.push('--branch', ref);
|
|
209
|
+
args.push(repo.url, targetDir);
|
|
210
|
+
await execa('git', args);
|
|
211
|
+
fs.rmSync(path.join(targetDir, '.git'), { recursive: true, force: true });
|
|
212
|
+
clearInterval(tick);
|
|
213
|
+
cloneSpinner.succeed(`Repository cloned${ref ? ` (${ref})` : ''}`);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
clearInterval(tick);
|
|
216
|
+
cloneSpinner.fail('Failed to clone repository');
|
|
217
|
+
log.error(
|
|
218
|
+
ref
|
|
219
|
+
? `Could not clone ref "${ref}". Check the branch or tag exists.`
|
|
220
|
+
: error.message
|
|
221
|
+
);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Safety net: the ref resolved above should always carry the chosen
|
|
226
|
+
// frontend, but a stale --ref or a renamed directory would slip through and
|
|
227
|
+
// fail much later on a dev script that does not exist.
|
|
228
|
+
const available = detectFrameworks(targetDir);
|
|
229
|
+
if (!available.some((f) => f.name === framework.name)) {
|
|
230
|
+
console.log();
|
|
231
|
+
log.error(
|
|
232
|
+
`This ref has no ${framework.name}/ directory, so ${framework.label} cannot run.`
|
|
233
|
+
);
|
|
234
|
+
console.log(
|
|
235
|
+
` Available here: ${available.map((f) => f.name).join(', ') || 'none'}`
|
|
236
|
+
);
|
|
237
|
+
if (options.ref)
|
|
238
|
+
console.log(' Try omitting --ref, or pick a ref that has it.');
|
|
239
|
+
console.log();
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The clone's history is removed above, so give the user a repo of their own
|
|
244
|
+
// rather than leaving the directory untracked.
|
|
245
|
+
if (options.git) {
|
|
246
|
+
try {
|
|
247
|
+
await execa('git', ['init', '-q'], { cwd: targetDir });
|
|
248
|
+
await execa('git', ['add', '-A'], { cwd: targetDir });
|
|
249
|
+
await execa(
|
|
250
|
+
'git',
|
|
251
|
+
['commit', '-q', '-m', 'Initial commit from create-strapi-launchpad'],
|
|
252
|
+
{ cwd: targetDir }
|
|
253
|
+
);
|
|
254
|
+
log.success('Initialized a git repository');
|
|
255
|
+
} catch {
|
|
256
|
+
// A missing git identity is the usual cause. Not worth failing the run.
|
|
257
|
+
log.warn('Could not create the initial commit — continuing');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- Install ---
|
|
262
|
+
nextStep('Installing dependencies...');
|
|
263
|
+
const installSpinner = ora('Running setup...').start();
|
|
264
|
+
try {
|
|
265
|
+
await execa(PM, ['install'], { cwd: targetDir });
|
|
266
|
+
await execa(PM, ['setup'], { cwd: targetDir });
|
|
267
|
+
|
|
268
|
+
// setup writes the shared PREVIEW_SECRET to every frontend, but leaves
|
|
269
|
+
// CLIENT_URL at its default of Next. Strapi reads CLIENT_URL to build the
|
|
270
|
+
// admin's Preview link, so without this the Preview button opens Next
|
|
271
|
+
// whatever frontend was chosen.
|
|
272
|
+
await execa(PM, ['use', framework.name], { cwd: targetDir });
|
|
273
|
+
|
|
274
|
+
installSpinner.succeed('Dependencies installed');
|
|
275
|
+
} catch (error) {
|
|
276
|
+
installSpinner.fail('Failed to install dependencies');
|
|
277
|
+
log.error(error.message);
|
|
278
|
+
process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- Seed ---
|
|
282
|
+
if (options.seed) {
|
|
283
|
+
nextStep('Seeding demo data...');
|
|
284
|
+
const seedSpinner = ora('Seeding...').start();
|
|
285
|
+
try {
|
|
286
|
+
await execa(PM, ['seed'], { cwd: targetDir });
|
|
287
|
+
seedSpinner.succeed('Demo data seeded');
|
|
288
|
+
} catch (error) {
|
|
289
|
+
seedSpinner.fail('Failed to seed data');
|
|
290
|
+
log.error(error.message);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// --- Start ---
|
|
296
|
+
if (!options.start) {
|
|
297
|
+
nextStep('Finishing up');
|
|
298
|
+
console.log();
|
|
299
|
+
log.success('LaunchPad is ready! To start:');
|
|
300
|
+
console.log(`\n cd ${directory}`);
|
|
301
|
+
console.log(` ${PM} ${framework.devScript}\n`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
nextStep('Starting development servers...');
|
|
306
|
+
|
|
307
|
+
const blocked = await findBlockedPorts(framework);
|
|
308
|
+
|
|
309
|
+
if (blocked.length > 0) {
|
|
310
|
+
console.log();
|
|
311
|
+
reportBlockedPorts(blocked);
|
|
312
|
+
console.log();
|
|
313
|
+
console.log(
|
|
314
|
+
' Either stop whatever is using them, or change the ports in:'
|
|
315
|
+
);
|
|
316
|
+
const envPaths = [
|
|
317
|
+
[`${directory}/strapi/.env`, 'Strapi'],
|
|
318
|
+
[`${directory}/${framework.name}/.env`, framework.label],
|
|
319
|
+
];
|
|
320
|
+
const width = Math.max(...envPaths.map(([envPath]) => envPath.length));
|
|
321
|
+
for (const [envPath, label] of envPaths) {
|
|
322
|
+
console.log(` ${envPath.padEnd(width)} → ${label}`);
|
|
323
|
+
}
|
|
324
|
+
console.log();
|
|
325
|
+
console.log(' Then start it yourself:');
|
|
326
|
+
console.log(` cd ${directory}`);
|
|
327
|
+
console.log(` ${PM} ${framework.devScript}`);
|
|
328
|
+
console.log();
|
|
329
|
+
process.exit(1);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
console.log();
|
|
333
|
+
log.info(`Strapi admin → http://localhost:${STRAPI_PORT}/admin`);
|
|
334
|
+
log.info(`${framework.label} → http://localhost:${framework.port}`);
|
|
335
|
+
console.log();
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
await execa(PM, [framework.devScript], {
|
|
339
|
+
cwd: targetDir,
|
|
340
|
+
stdio: 'inherit',
|
|
341
|
+
});
|
|
342
|
+
} catch {
|
|
343
|
+
// Ctrl+C is the normal way out of a dev server.
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frontends LaunchPad ships.
|
|
3
|
+
*
|
|
4
|
+
* This mirrors `scripts/frontends.mts` in the LaunchPad repo. Keep the two in
|
|
5
|
+
* step: the ports and dev scripts here have to match what that repo actually
|
|
6
|
+
* runs, or the CLI will check the wrong port and call a script that does not
|
|
7
|
+
* exist.
|
|
8
|
+
*
|
|
9
|
+
* Adding a frontend should be one entry — the prompt, the `--framework`
|
|
10
|
+
* choices, the port check, the dev command and the help text all read from
|
|
11
|
+
* this list.
|
|
12
|
+
*/
|
|
13
|
+
export const FRAMEWORKS = [
|
|
14
|
+
{ name: 'next', label: 'Next.js', port: 3000, devScript: 'dev' },
|
|
15
|
+
{ name: 'astro', label: 'Astro', port: 4321, devScript: 'dev:astro' },
|
|
16
|
+
{ name: 'nuxt', label: 'Nuxt 4', port: 3001, devScript: 'dev:nuxt' },
|
|
17
|
+
{
|
|
18
|
+
name: 'tanstack',
|
|
19
|
+
label: 'TanStack Start',
|
|
20
|
+
port: 3002,
|
|
21
|
+
devScript: 'dev:tanstack',
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Port Strapi listens on. Shared by every frontend. */
|
|
26
|
+
export const STRAPI_PORT = 1337;
|
|
27
|
+
|
|
28
|
+
/** The frontend used when none is chosen. */
|
|
29
|
+
export const DEFAULT_FRAMEWORK = 'next';
|
|
30
|
+
|
|
31
|
+
export const frameworkNames = () => FRAMEWORKS.map((f) => f.name);
|
|
32
|
+
|
|
33
|
+
export function getFramework(name) {
|
|
34
|
+
return FRAMEWORKS.find((f) => f.name === name) ?? null;
|
|
35
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
export const log = {
|
|
4
|
+
info: (msg) => console.log(chalk.blue('ℹ'), msg),
|
|
5
|
+
success: (msg) => console.log(chalk.green('✔'), msg),
|
|
6
|
+
warn: (msg) => console.log(chalk.yellow('⚠'), msg),
|
|
7
|
+
error: (msg) => console.error(chalk.red('✖'), msg),
|
|
8
|
+
step: (step, total, msg) =>
|
|
9
|
+
console.log(chalk.cyan(`[${step}/${total}]`), msg),
|
|
10
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether a port is free.
|
|
6
|
+
*
|
|
7
|
+
* This connects rather than binds. Binding looks obvious but is unreliable
|
|
8
|
+
* here: servers pick different addresses (Strapi takes IPv4 `0.0.0.0`, Astro
|
|
9
|
+
* takes IPv6 `[::1]`), and on BSD a wildcard bind happily coexists with a
|
|
10
|
+
* loopback one — so a bind probe reported a busy port as free. If something
|
|
11
|
+
* accepts a connection, the port is taken, whatever it bound to.
|
|
12
|
+
*/
|
|
13
|
+
function canConnect(port, host, timeout = 400) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const socket = new net.Socket();
|
|
16
|
+
const done = (result) => {
|
|
17
|
+
socket.destroy();
|
|
18
|
+
resolve(result);
|
|
19
|
+
};
|
|
20
|
+
socket.setTimeout(timeout);
|
|
21
|
+
socket.once('connect', () => done(true));
|
|
22
|
+
socket.once('timeout', () => done(false));
|
|
23
|
+
socket.once('error', () => done(false));
|
|
24
|
+
socket.connect(port, host);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function isPortAvailable(port) {
|
|
29
|
+
const reachable = await Promise.all([
|
|
30
|
+
canConnect(port, '127.0.0.1'),
|
|
31
|
+
canConnect(port, '::1'),
|
|
32
|
+
]);
|
|
33
|
+
return !reachable.some(Boolean);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The working directory of whatever is listening on a port, if it can be
|
|
38
|
+
* determined.
|
|
39
|
+
*
|
|
40
|
+
* Knowing a port is busy is much less useful than knowing which project owns
|
|
41
|
+
* it. Two LaunchPad checkouts each have their own PREVIEW_SECRET, so a
|
|
42
|
+
* frontend left running from a different one answers the admin's preview
|
|
43
|
+
* request and fails it with "Invalid token" — a confusing way to discover a
|
|
44
|
+
* port conflict.
|
|
45
|
+
*
|
|
46
|
+
* Best effort: lsof is not available everywhere, and the answer is only a
|
|
47
|
+
* hint, so any failure returns null rather than interrupting the run.
|
|
48
|
+
*/
|
|
49
|
+
export async function portOwner(port) {
|
|
50
|
+
try {
|
|
51
|
+
// -sTCP:LISTEN matters: without it lsof also returns clients *connected*
|
|
52
|
+
// to the port, and a browser tab's connection would be reported as the
|
|
53
|
+
// owner.
|
|
54
|
+
const { stdout: pids } = await execa('lsof', [
|
|
55
|
+
'-nP',
|
|
56
|
+
'-tiTCP:' + port,
|
|
57
|
+
'-sTCP:LISTEN',
|
|
58
|
+
]);
|
|
59
|
+
const pid = pids.split('\n')[0]?.trim();
|
|
60
|
+
if (!pid) return null;
|
|
61
|
+
|
|
62
|
+
const { stdout } = await execa('lsof', [
|
|
63
|
+
'-a',
|
|
64
|
+
'-p',
|
|
65
|
+
pid,
|
|
66
|
+
'-d',
|
|
67
|
+
'cwd',
|
|
68
|
+
'-Fn',
|
|
69
|
+
]);
|
|
70
|
+
const cwd = stdout
|
|
71
|
+
.split('\n')
|
|
72
|
+
.find((line) => line.startsWith('n'))
|
|
73
|
+
?.slice(1);
|
|
74
|
+
return cwd ?? null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { execaCommand } from 'execa';
|
|
2
|
+
|
|
3
|
+
import { log } from './logger.js';
|
|
4
|
+
|
|
5
|
+
// LaunchPad's frontends need this: Nuxt 4 and Astro 6 both require 20.19+.
|
|
6
|
+
// The old floor of 18 let people through to a confusing failure during install.
|
|
7
|
+
const MIN_NODE = [20, 19, 0];
|
|
8
|
+
|
|
9
|
+
function parseVersion(version) {
|
|
10
|
+
return version.split('.').map((n) => parseInt(n, 10));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isAtLeast(actual, required) {
|
|
14
|
+
for (let i = 0; i < required.length; i++) {
|
|
15
|
+
const a = actual[i] ?? 0;
|
|
16
|
+
if (a > required[i]) return true;
|
|
17
|
+
if (a < required[i]) return false;
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function checkPrerequisites() {
|
|
23
|
+
const nodeVersion = process.versions.node;
|
|
24
|
+
|
|
25
|
+
if (!isAtLeast(parseVersion(nodeVersion), MIN_NODE)) {
|
|
26
|
+
log.error(
|
|
27
|
+
`Node.js ${MIN_NODE.join('.')} or higher is required. You are running v${nodeVersion}.`
|
|
28
|
+
);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
log.success(`Node.js v${nodeVersion}`);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
await execaCommand('git --version');
|
|
35
|
+
} catch {
|
|
36
|
+
log.error('Git is not installed. Install it and try again.');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// LaunchPad pins yarn@4.5.0 in its root package.json, so this is the only
|
|
41
|
+
// package manager that will work. Corepack ships with Node and can provide
|
|
42
|
+
// it without a global install.
|
|
43
|
+
try {
|
|
44
|
+
await execaCommand('yarn --version');
|
|
45
|
+
log.success('Yarn available');
|
|
46
|
+
} catch {
|
|
47
|
+
log.warn('Yarn not found — enabling it through corepack...');
|
|
48
|
+
try {
|
|
49
|
+
await execaCommand('corepack enable');
|
|
50
|
+
log.success('Corepack enabled, yarn is now available');
|
|
51
|
+
} catch {
|
|
52
|
+
log.error(
|
|
53
|
+
'Could not enable yarn. Install it manually:\n' +
|
|
54
|
+
' corepack enable\n' +
|
|
55
|
+
' — or —\n' +
|
|
56
|
+
' npm install -g yarn\n\n' +
|
|
57
|
+
'LaunchPad pins yarn@4.5.0, so npm and pnpm will not work.'
|
|
58
|
+
);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|