abw-react-starter 1.0.0 → 1.0.2
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/README.md +175 -0
- package/bin/create.js +456 -60
- package/package.json +8 -3
package/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# 🚀 abw-react-starter
|
|
2
|
+
|
|
3
|
+
CLI to quickly bootstrap a fullstack project with:
|
|
4
|
+
|
|
5
|
+
* ⚙️ **Strapi (Backend)**
|
|
6
|
+
* ⚡ **Next.js (Frontend)**
|
|
7
|
+
* ☁️ **Heroku deployment (optional)**
|
|
8
|
+
* 🪣 **AWS S3 upload support (optional)**
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 📦 Installation
|
|
13
|
+
|
|
14
|
+
### Option 1: Use without installing (recommended)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx abw-react-starter create my-app
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Option 2: Install globally
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install -g abw-react-starter
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Then:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
abw-react-starter create my-app
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## 🚀 Usage
|
|
35
|
+
|
|
36
|
+
### Create a new project
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
abw-react-starter create my-app
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
or simply:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
abw-react-starter my-app
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## ⚙️ What it does
|
|
51
|
+
|
|
52
|
+
* Creates a full project structure:
|
|
53
|
+
|
|
54
|
+
* `/backend` → Strapi (TypeScript)
|
|
55
|
+
* `/frontend` → Next.js
|
|
56
|
+
* Initializes Git repositories
|
|
57
|
+
* Configures environment variables
|
|
58
|
+
* Optionally sets up:
|
|
59
|
+
|
|
60
|
+
* AWS S3 uploads
|
|
61
|
+
* Heroku deployment (backend + frontend)
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## ☁️ Deployment
|
|
66
|
+
|
|
67
|
+
During setup, you will be asked:
|
|
68
|
+
|
|
69
|
+
* Whether to deploy to Heroku
|
|
70
|
+
* App names for backend and frontend
|
|
71
|
+
* Login (if not authenticated)
|
|
72
|
+
|
|
73
|
+
### Or deploy later:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
abw-react-starter publish my-app
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 🪣 AWS S3 Support
|
|
82
|
+
|
|
83
|
+
If enabled, the CLI will:
|
|
84
|
+
|
|
85
|
+
* Install S3 upload provider
|
|
86
|
+
* Configure:
|
|
87
|
+
|
|
88
|
+
* `plugins.ts`
|
|
89
|
+
* `middlewares.ts`
|
|
90
|
+
* Ask for:
|
|
91
|
+
|
|
92
|
+
* Bucket name
|
|
93
|
+
* Region
|
|
94
|
+
* Access keys
|
|
95
|
+
* Automatically configure Heroku env variables
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## 🔐 Requirements
|
|
100
|
+
|
|
101
|
+
Make sure you have installed:
|
|
102
|
+
|
|
103
|
+
* Node.js (>= 18)
|
|
104
|
+
* npm
|
|
105
|
+
* Git
|
|
106
|
+
* Heroku CLI
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## 🧪 Local Development
|
|
111
|
+
|
|
112
|
+
### Backend
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
cd backend
|
|
116
|
+
npm run develop
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Frontend
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
cd frontend
|
|
123
|
+
npm run dev
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 📁 Project Structure
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
my-app/
|
|
132
|
+
├── backend/ # Strapi API
|
|
133
|
+
├── frontend/ # Next.js app
|
|
134
|
+
└── .abw-starter.json
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## 🔄 Updating
|
|
140
|
+
|
|
141
|
+
If installed globally:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
npm update -g abw-react-starter
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## 🐛 Troubleshooting
|
|
150
|
+
|
|
151
|
+
### Heroku app name already taken
|
|
152
|
+
|
|
153
|
+
The CLI will automatically:
|
|
154
|
+
|
|
155
|
+
1. Try your chosen name
|
|
156
|
+
2. Try a random suffix
|
|
157
|
+
3. Fallback to Heroku-generated name
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
### Not logged into Heroku
|
|
162
|
+
|
|
163
|
+
The CLI will prompt you to login automatically.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## 📄 License
|
|
168
|
+
|
|
169
|
+
MIT
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## 👨💻 Author
|
|
174
|
+
|
|
175
|
+
Built with ❤️ by ABWorks
|
package/bin/create.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import inquirer from "inquirer";
|
|
@@ -18,6 +18,10 @@ function randHex(bytes = 16) {
|
|
|
18
18
|
return crypto.randomBytes(bytes).toString("hex");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function randSuffix(size = 6) {
|
|
22
|
+
return crypto.randomBytes(size).toString("hex").slice(0, size);
|
|
23
|
+
}
|
|
24
|
+
|
|
21
25
|
async function run(cmd, args, opts = {}) {
|
|
22
26
|
const p = execa(cmd, args, {
|
|
23
27
|
stdio: "inherit",
|
|
@@ -31,6 +35,32 @@ async function runCapture(cmd, args, opts = {}) {
|
|
|
31
35
|
return p.stdout?.trim();
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
async function runCombined(cmd, args, opts = {}) {
|
|
39
|
+
try {
|
|
40
|
+
const result = await execa(cmd, args, {
|
|
41
|
+
all: true,
|
|
42
|
+
...opts,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
ok: true,
|
|
47
|
+
code: 0,
|
|
48
|
+
output: result.all?.trim() || "",
|
|
49
|
+
stdout: result.stdout?.trim() || "",
|
|
50
|
+
stderr: result.stderr?.trim() || "",
|
|
51
|
+
};
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
code: err?.exitCode ?? 1,
|
|
56
|
+
output: String(err?.all || err?.stderr || err?.stdout || err?.message || "").trim(),
|
|
57
|
+
stdout: String(err?.stdout || "").trim(),
|
|
58
|
+
stderr: String(err?.stderr || "").trim(),
|
|
59
|
+
error: err,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
34
64
|
function ensureNoTrailingSlash(url) {
|
|
35
65
|
return (url || "").replace(/\/+$/, "");
|
|
36
66
|
}
|
|
@@ -43,6 +73,120 @@ async function assertCmdExists(cmd, args = ["--version"]) {
|
|
|
43
73
|
}
|
|
44
74
|
}
|
|
45
75
|
|
|
76
|
+
function looksLikeHerokuNameTakenError(output) {
|
|
77
|
+
const msg = String(output || "").toLowerCase();
|
|
78
|
+
return (
|
|
79
|
+
msg.includes("name") &&
|
|
80
|
+
(msg.includes("taken") ||
|
|
81
|
+
msg.includes("already taken") ||
|
|
82
|
+
msg.includes("already exists") ||
|
|
83
|
+
msg.includes("invalid_params"))
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function getHerokuAppInfo(appName, cwd) {
|
|
88
|
+
const raw = await runCapture("heroku", ["apps:info", "-a", appName, "--json"], { cwd });
|
|
89
|
+
return JSON.parse(raw || "{}");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function tryCreateHerokuApp(appName, region, cwd) {
|
|
93
|
+
const args = ["create"];
|
|
94
|
+
if (appName) args.push(appName);
|
|
95
|
+
args.push("--region", region);
|
|
96
|
+
|
|
97
|
+
return runCombined("heroku", args, { cwd });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function createHerokuAppWithFallback({ cwd, requestedName, region, label }) {
|
|
101
|
+
const attempts = [];
|
|
102
|
+
|
|
103
|
+
if (requestedName) {
|
|
104
|
+
attempts.push(requestedName);
|
|
105
|
+
attempts.push(`${requestedName}-${randSuffix(4)}`);
|
|
106
|
+
attempts.push(`${requestedName}-${randSuffix(6)}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const candidate of attempts) {
|
|
110
|
+
console.log(`\n=== ${label}: tentar criar app Heroku "${candidate}" ===\n`);
|
|
111
|
+
|
|
112
|
+
const res = await tryCreateHerokuApp(candidate, region, cwd);
|
|
113
|
+
|
|
114
|
+
if (res.output) {
|
|
115
|
+
console.log(res.output);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (res.ok) {
|
|
119
|
+
return candidate;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (looksLikeHerokuNameTakenError(res.output)) {
|
|
123
|
+
console.log(`⚠️ Nome já ocupado: ${candidate}`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
throw res.error || new Error(`Falha ao criar app Heroku (${label}).`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log(`\n=== ${label}: a criar app Heroku com nome automático ===\n`);
|
|
131
|
+
|
|
132
|
+
const autoRes = await tryCreateHerokuApp(null, region, cwd);
|
|
133
|
+
|
|
134
|
+
if (autoRes.output) {
|
|
135
|
+
console.log(autoRes.output);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!autoRes.ok) {
|
|
139
|
+
throw autoRes.error || new Error(`Falha ao criar app Heroku com nome automático (${label}).`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const appsRaw = await runCapture("heroku", ["apps", "--json"], { cwd });
|
|
143
|
+
const apps = JSON.parse(appsRaw || "[]");
|
|
144
|
+
|
|
145
|
+
if (!Array.isArray(apps) || apps.length === 0) {
|
|
146
|
+
throw new Error(`Não consegui descobrir o nome final da app Heroku (${label}).`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
apps.sort((a, b) => {
|
|
150
|
+
const da = new Date(a?.created_at || 0).getTime();
|
|
151
|
+
const db = new Date(b?.created_at || 0).getTime();
|
|
152
|
+
return db - da;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const latest = apps[0]?.name;
|
|
156
|
+
if (!latest) {
|
|
157
|
+
throw new Error(`Não consegui descobrir o nome final da app Heroku (${label}).`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return latest;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function upsertEnvValue(content, key, value) {
|
|
164
|
+
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
165
|
+
const regex = new RegExp(`^${escapedKey}=.*$`, "m");
|
|
166
|
+
const line = `${key}=${value}`;
|
|
167
|
+
|
|
168
|
+
if (regex.test(content)) {
|
|
169
|
+
return content.replace(regex, line);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const trimmed = content.replace(/\s*$/, "");
|
|
173
|
+
return trimmed ? `${trimmed}\n${line}\n` : `${line}\n`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function mergeEnvFile(filePath, values) {
|
|
177
|
+
let content = "";
|
|
178
|
+
|
|
179
|
+
if (await fs.pathExists(filePath)) {
|
|
180
|
+
content = await fs.readFile(filePath, "utf8");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const [key, value] of Object.entries(values)) {
|
|
184
|
+
content = upsertEnvValue(content, key, value ?? "");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await fs.writeFile(filePath, content);
|
|
188
|
+
}
|
|
189
|
+
|
|
46
190
|
/* =========================
|
|
47
191
|
Heroku Login (melhorado)
|
|
48
192
|
========================= */
|
|
@@ -85,7 +229,8 @@ async function ensureHerokuLoginOrSkip() {
|
|
|
85
229
|
========================= */
|
|
86
230
|
|
|
87
231
|
async function writeManifest(projectDir, data) {
|
|
88
|
-
await
|
|
232
|
+
const current = (await readManifest(projectDir)) || {};
|
|
233
|
+
await fs.writeJson(path.join(projectDir, MANIFEST_NAME), { ...current, ...data }, {
|
|
89
234
|
spaces: 2,
|
|
90
235
|
});
|
|
91
236
|
}
|
|
@@ -129,6 +274,7 @@ async function configureBackendS3(projectDir, aws) {
|
|
|
129
274
|
|
|
130
275
|
const bucketUrl = `https://${aws.bucket}.s3.${aws.region}.amazonaws.com`;
|
|
131
276
|
|
|
277
|
+
console.log("\n=== BACKEND: configurar middlewares.ts ===\n");
|
|
132
278
|
await fs.writeFile(
|
|
133
279
|
path.join(beDir, "config", "middlewares.ts"),
|
|
134
280
|
`export default [
|
|
@@ -157,6 +303,7 @@ async function configureBackendS3(projectDir, aws) {
|
|
|
157
303
|
`
|
|
158
304
|
);
|
|
159
305
|
|
|
306
|
+
console.log("\n=== BACKEND: configurar plugins.ts ===\n");
|
|
160
307
|
await fs.writeFile(
|
|
161
308
|
path.join(beDir, "config", "plugins.ts"),
|
|
162
309
|
`export default ({ env }) => ({
|
|
@@ -176,20 +323,48 @@ async function configureBackendS3(projectDir, aws) {
|
|
|
176
323
|
},
|
|
177
324
|
},
|
|
178
325
|
},
|
|
326
|
+
actionOptions: {
|
|
327
|
+
upload: {},
|
|
328
|
+
uploadStream: {},
|
|
329
|
+
delete: {},
|
|
330
|
+
},
|
|
179
331
|
},
|
|
180
332
|
},
|
|
181
333
|
});
|
|
182
334
|
`
|
|
183
335
|
);
|
|
184
336
|
|
|
337
|
+
console.log("\n=== BACKEND: configurar server.ts ===\n");
|
|
185
338
|
await fs.writeFile(
|
|
186
|
-
path.join(beDir, ".
|
|
187
|
-
`
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
339
|
+
path.join(beDir, "config", "server.ts"),
|
|
340
|
+
`import type { Core } from '@strapi/strapi';
|
|
341
|
+
|
|
342
|
+
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Server => {
|
|
343
|
+
return {
|
|
344
|
+
host: env('HOST', '0.0.0.0'),
|
|
345
|
+
port: env.int('PORT', 1337),
|
|
346
|
+
app: {
|
|
347
|
+
keys: env.array('APP_KEYS'),
|
|
348
|
+
},
|
|
349
|
+
transfer: {
|
|
350
|
+
remote: {
|
|
351
|
+
enabled: true,
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
export default config;
|
|
191
358
|
`
|
|
192
359
|
);
|
|
360
|
+
|
|
361
|
+
console.log("\n=== BACKEND: atualizar .env ===\n");
|
|
362
|
+
await mergeEnvFile(path.join(beDir, ".env"), {
|
|
363
|
+
AWS_ACCESS_KEY_ID: aws.key,
|
|
364
|
+
AWS_ACCESS_SECRET: aws.secret,
|
|
365
|
+
AWS_REGION: aws.region,
|
|
366
|
+
AWS_BUCKET: aws.bucket,
|
|
367
|
+
});
|
|
193
368
|
}
|
|
194
369
|
|
|
195
370
|
/* =========================
|
|
@@ -234,6 +409,31 @@ async function createFrontendLocal(projectDir, nodeVersion) {
|
|
|
234
409
|
});
|
|
235
410
|
|
|
236
411
|
const feDir = path.join(projectDir, "frontend");
|
|
412
|
+
|
|
413
|
+
const nextConfigTs = path.join(feDir, "next.config.ts");
|
|
414
|
+
const nextConfigJs = path.join(feDir, "next.config.js");
|
|
415
|
+
|
|
416
|
+
if (await fs.pathExists(nextConfigTs)) {
|
|
417
|
+
await fs.remove(nextConfigTs);
|
|
418
|
+
await fs.writeFile(
|
|
419
|
+
nextConfigJs,
|
|
420
|
+
`/** @type {import('next').NextConfig} */
|
|
421
|
+
const nextConfig = {};
|
|
422
|
+
|
|
423
|
+
module.exports = nextConfig;
|
|
424
|
+
`
|
|
425
|
+
);
|
|
426
|
+
} else if (!(await fs.pathExists(nextConfigJs))) {
|
|
427
|
+
await fs.writeFile(
|
|
428
|
+
nextConfigJs,
|
|
429
|
+
`/** @type {import('next').NextConfig} */
|
|
430
|
+
const nextConfig = {};
|
|
431
|
+
|
|
432
|
+
module.exports = nextConfig;
|
|
433
|
+
`
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
237
437
|
const pkgPath = path.join(feDir, "package.json");
|
|
238
438
|
const pkg = await fs.readJson(pkgPath);
|
|
239
439
|
|
|
@@ -241,10 +441,16 @@ async function createFrontendLocal(projectDir, nodeVersion) {
|
|
|
241
441
|
pkg.engines.node = nodeVersion;
|
|
242
442
|
|
|
243
443
|
pkg.scripts = pkg.scripts || {};
|
|
444
|
+
pkg.scripts.build = pkg.scripts.build || "next build";
|
|
244
445
|
pkg.scripts.start = "next start -p $PORT";
|
|
245
446
|
|
|
246
447
|
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
247
448
|
|
|
449
|
+
const envLocalPath = path.join(feDir, ".env.local");
|
|
450
|
+
if (!(await fs.pathExists(envLocalPath))) {
|
|
451
|
+
await fs.writeFile(envLocalPath, "NEXT_PUBLIC_STRAPI_URL=http://localhost:1337\n");
|
|
452
|
+
}
|
|
453
|
+
|
|
248
454
|
await initGitRepo(feDir, "Init Next frontend");
|
|
249
455
|
}
|
|
250
456
|
|
|
@@ -255,26 +461,77 @@ async function createFrontendLocal(projectDir, nodeVersion) {
|
|
|
255
461
|
async function deployBackendHeroku({ projectDir, backendApp, region }) {
|
|
256
462
|
const beDir = path.join(projectDir, "backend");
|
|
257
463
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
464
|
+
const finalBackendApp = await createHerokuAppWithFallback({
|
|
465
|
+
cwd: beDir,
|
|
466
|
+
requestedName: backendApp,
|
|
467
|
+
region,
|
|
468
|
+
label: "BACKEND",
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
await run("heroku", ["git:remote", "-a", finalBackendApp], { cwd: beDir });
|
|
472
|
+
await run("heroku", ["addons:create", "heroku-postgresql", "-a", finalBackendApp], { cwd: beDir });
|
|
473
|
+
|
|
474
|
+
console.log("\n=== BACKEND: configurar env vars Strapi ===\n");
|
|
475
|
+
const appKeys = [randHex(16), randHex(16), randHex(16), randHex(16)].join(",");
|
|
476
|
+
const apiTokenSalt = randHex(16);
|
|
477
|
+
const adminJwtSecret = randHex(24);
|
|
478
|
+
const jwtSecret = randHex(24);
|
|
479
|
+
|
|
480
|
+
await run("heroku", ["config:set", `NODE_ENV=production`, "-a", finalBackendApp], { cwd: beDir });
|
|
481
|
+
await run("heroku", ["config:set", `APP_KEYS=${appKeys}`, "-a", finalBackendApp], { cwd: beDir });
|
|
482
|
+
await run("heroku", ["config:set", `API_TOKEN_SALT=${apiTokenSalt}`, "-a", finalBackendApp], { cwd: beDir });
|
|
483
|
+
await run("heroku", ["config:set", `ADMIN_JWT_SECRET=${adminJwtSecret}`, "-a", finalBackendApp], { cwd: beDir });
|
|
484
|
+
await run("heroku", ["config:set", `JWT_SECRET=${jwtSecret}`, "-a", finalBackendApp], { cwd: beDir });
|
|
485
|
+
|
|
486
|
+
await run("heroku", ["config:set", `DATABASE_CLIENT=postgres`, "-a", finalBackendApp], { cwd: beDir });
|
|
487
|
+
await run("heroku", ["config:set", `DATABASE_SSL=true`, "-a", finalBackendApp], { cwd: beDir });
|
|
488
|
+
await run("heroku", ["config:set", `DATABASE_SSL_REJECT_UNAUTHORIZED=false`, "-a", finalBackendApp], {
|
|
489
|
+
cwd: beDir,
|
|
490
|
+
});
|
|
261
491
|
|
|
262
492
|
const envPath = path.join(beDir, ".env");
|
|
263
493
|
|
|
264
494
|
if (await fs.pathExists(envPath)) {
|
|
265
495
|
const env = await fs.readFile(envPath, "utf8");
|
|
266
|
-
const get = (k) => env.match(new RegExp(
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
496
|
+
const get = (k) => env.match(new RegExp(`^${k}=(.*)$`, "m"))?.[1]?.trim();
|
|
497
|
+
|
|
498
|
+
const awsBucket = get("AWS_BUCKET");
|
|
499
|
+
const awsRegion = get("AWS_REGION");
|
|
500
|
+
const awsAccessKeyId = get("AWS_ACCESS_KEY_ID");
|
|
501
|
+
const awsAccessSecret = get("AWS_ACCESS_SECRET");
|
|
502
|
+
|
|
503
|
+
if (awsBucket) {
|
|
504
|
+
await run("heroku", ["config:set", `AWS_BUCKET=${awsBucket}`, "-a", finalBackendApp], { cwd: beDir });
|
|
505
|
+
}
|
|
506
|
+
if (awsRegion) {
|
|
507
|
+
await run("heroku", ["config:set", `AWS_REGION=${awsRegion}`, "-a", finalBackendApp], { cwd: beDir });
|
|
508
|
+
}
|
|
509
|
+
if (awsAccessKeyId) {
|
|
510
|
+
await run("heroku", ["config:set", `AWS_ACCESS_KEY_ID=${awsAccessKeyId}`, "-a", finalBackendApp], {
|
|
511
|
+
cwd: beDir,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
if (awsAccessSecret) {
|
|
515
|
+
await run("heroku", ["config:set", `AWS_ACCESS_SECRET=${awsAccessSecret}`, "-a", finalBackendApp], {
|
|
516
|
+
cwd: beDir,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
272
519
|
}
|
|
273
520
|
|
|
521
|
+
console.log("\n=== BACKEND: deploy ===\n");
|
|
274
522
|
await run("git", ["push", "heroku", "main"], { cwd: beDir });
|
|
275
523
|
|
|
276
|
-
const info =
|
|
277
|
-
|
|
524
|
+
const info = await getHerokuAppInfo(finalBackendApp, beDir);
|
|
525
|
+
const backendWebUrl = ensureNoTrailingSlash(info?.app?.web_url);
|
|
526
|
+
|
|
527
|
+
if (!backendWebUrl) {
|
|
528
|
+
throw new Error("Não consegui obter web_url do backend no Heroku.");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
backendApp: finalBackendApp,
|
|
533
|
+
backendWebUrl,
|
|
534
|
+
};
|
|
278
535
|
}
|
|
279
536
|
|
|
280
537
|
/* =========================
|
|
@@ -284,17 +541,34 @@ async function deployBackendHeroku({ projectDir, backendApp, region }) {
|
|
|
284
541
|
async function deployFrontendHeroku({ projectDir, frontendApp, region, backendWebUrl }) {
|
|
285
542
|
const feDir = path.join(projectDir, "frontend");
|
|
286
543
|
|
|
287
|
-
|
|
288
|
-
|
|
544
|
+
const finalFrontendApp = await createHerokuAppWithFallback({
|
|
545
|
+
cwd: feDir,
|
|
546
|
+
requestedName: frontendApp,
|
|
547
|
+
region,
|
|
548
|
+
label: "FRONTEND",
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
await run("heroku", ["git:remote", "-a", finalFrontendApp], { cwd: feDir });
|
|
289
552
|
|
|
290
|
-
|
|
553
|
+
console.log("\n=== FRONTEND: configurar env var ===\n");
|
|
554
|
+
await run("heroku", ["config:set", `NEXT_PUBLIC_STRAPI_URL=${backendWebUrl}`, "-a", finalFrontendApp], {
|
|
291
555
|
cwd: feDir,
|
|
292
556
|
});
|
|
293
557
|
|
|
558
|
+
console.log("\n=== FRONTEND: deploy ===\n");
|
|
294
559
|
await run("git", ["push", "heroku", "main"], { cwd: feDir });
|
|
295
560
|
|
|
296
|
-
const info =
|
|
297
|
-
|
|
561
|
+
const info = await getHerokuAppInfo(finalFrontendApp, feDir);
|
|
562
|
+
const frontendWebUrl = ensureNoTrailingSlash(info?.app?.web_url);
|
|
563
|
+
|
|
564
|
+
if (!frontendWebUrl) {
|
|
565
|
+
throw new Error("Não consegui obter web_url do frontend no Heroku.");
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
frontendApp: finalFrontendApp,
|
|
570
|
+
frontendWebUrl,
|
|
571
|
+
};
|
|
298
572
|
}
|
|
299
573
|
|
|
300
574
|
/* =========================
|
|
@@ -313,7 +587,6 @@ async function doCreate(projectDirArg, opts) {
|
|
|
313
587
|
const region = (opts.region || "eu").toLowerCase();
|
|
314
588
|
const nodeVersion = opts.node || "20.x";
|
|
315
589
|
|
|
316
|
-
// S3
|
|
317
590
|
const { useS3 } = await inquirer.prompt([
|
|
318
591
|
{ type: "confirm", name: "useS3", message: "Usar S3?", default: true },
|
|
319
592
|
]);
|
|
@@ -323,9 +596,9 @@ async function doCreate(projectDirArg, opts) {
|
|
|
323
596
|
if (useS3) {
|
|
324
597
|
aws = await inquirer.prompt([
|
|
325
598
|
{ name: "bucket", message: "Bucket:" },
|
|
326
|
-
{ name: "region", default: "eu-north-1" },
|
|
599
|
+
{ name: "region", message: "Region:", default: "eu-north-1" },
|
|
327
600
|
{ name: "key", message: "Access Key:" },
|
|
328
|
-
{ type: "password", name: "secret", message: "Secret:" },
|
|
601
|
+
{ type: "password", name: "secret", message: "Secret:", mask: "*" },
|
|
329
602
|
]);
|
|
330
603
|
}
|
|
331
604
|
|
|
@@ -347,7 +620,9 @@ async function doCreate(projectDirArg, opts) {
|
|
|
347
620
|
|
|
348
621
|
await createBackendLocal(projectDir);
|
|
349
622
|
|
|
350
|
-
if (useS3
|
|
623
|
+
if (useS3 && aws) {
|
|
624
|
+
await configureBackendS3(projectDir, aws);
|
|
625
|
+
}
|
|
351
626
|
|
|
352
627
|
await createFrontendLocal(projectDir, nodeVersion);
|
|
353
628
|
|
|
@@ -358,91 +633,212 @@ async function doCreate(projectDirArg, opts) {
|
|
|
358
633
|
backendApp,
|
|
359
634
|
frontendApp,
|
|
360
635
|
useS3,
|
|
361
|
-
awsBucket: aws?.bucket,
|
|
362
|
-
awsRegion: aws?.region,
|
|
636
|
+
awsBucket: aws?.bucket || null,
|
|
637
|
+
awsRegion: aws?.region || null,
|
|
363
638
|
createdAt: new Date().toISOString(),
|
|
639
|
+
deployedAt: null,
|
|
640
|
+
backendUrl: null,
|
|
641
|
+
frontendUrl: null,
|
|
364
642
|
});
|
|
365
643
|
|
|
366
|
-
if (!deployNow)
|
|
644
|
+
if (!deployNow) {
|
|
645
|
+
console.log("\n✅ Projeto criado localmente (sem Heroku).");
|
|
646
|
+
console.log(`📁 Pasta: ${projectDir}`);
|
|
647
|
+
console.log("\nPara correr local:");
|
|
648
|
+
console.log("- Backend: cd backend && npm run develop");
|
|
649
|
+
console.log("- Frontend: cd frontend && npm run dev");
|
|
650
|
+
console.log("\nQuando quiseres publicar:");
|
|
651
|
+
console.log(`- abw-react-starter publish "${projectDir}"`);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const publishAnswers = await inquirer.prompt([
|
|
656
|
+
{
|
|
657
|
+
type: "input",
|
|
658
|
+
name: "backendApp",
|
|
659
|
+
message: "Nome da app Heroku do backend (Strapi):",
|
|
660
|
+
default: backendApp,
|
|
661
|
+
validate: (v) => (v?.length >= 3 ? true : "Nome muito curto"),
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
type: "input",
|
|
665
|
+
name: "frontendApp",
|
|
666
|
+
message: "Nome da app Heroku do frontend (Next):",
|
|
667
|
+
default: frontendApp,
|
|
668
|
+
validate: (v) => (v?.length >= 3 ? true : "Nome muito curto"),
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
type: "confirm",
|
|
672
|
+
name: "confirm",
|
|
673
|
+
message: "Confirmas criar 2 apps Heroku e fazer deploy?",
|
|
674
|
+
default: true,
|
|
675
|
+
},
|
|
676
|
+
]);
|
|
677
|
+
|
|
678
|
+
if (!publishAnswers.confirm) {
|
|
679
|
+
console.log("\n✅ Projeto criado localmente (não publicaste no Heroku).");
|
|
680
|
+
console.log(`Quando quiseres publicar: abw-react-starter publish "${projectDir}"`);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
backendApp = publishAnswers.backendApp;
|
|
685
|
+
frontendApp = publishAnswers.frontendApp;
|
|
686
|
+
|
|
687
|
+
await writeManifest(projectDir, {
|
|
688
|
+
backendApp,
|
|
689
|
+
frontendApp,
|
|
690
|
+
});
|
|
367
691
|
|
|
368
692
|
const canPublish = await ensureHerokuLoginOrSkip();
|
|
369
|
-
if (!canPublish) return;
|
|
370
693
|
|
|
371
|
-
|
|
694
|
+
if (!canPublish) {
|
|
695
|
+
console.log("\n✅ Projeto criado localmente (sem publicar no Heroku).");
|
|
696
|
+
console.log(`📁 Pasta: ${projectDir}`);
|
|
697
|
+
console.log("\nPara publicar mais tarde:");
|
|
698
|
+
console.log(`- abw-react-starter publish "${projectDir}"`);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
await doPublish(projectDir, { skipHerokuLoginCheck: true });
|
|
372
703
|
}
|
|
373
704
|
|
|
374
705
|
/* =========================
|
|
375
706
|
Publish
|
|
376
707
|
========================= */
|
|
377
708
|
|
|
378
|
-
async function doPublish(projectDirArg) {
|
|
709
|
+
async function doPublish(projectDirArg, options = {}) {
|
|
710
|
+
const { skipHerokuLoginCheck = false } = options;
|
|
379
711
|
const projectDir = resolveProjectDir(projectDirArg);
|
|
380
712
|
|
|
381
713
|
await assertCmdExists("git");
|
|
382
714
|
await assertCmdExists("heroku");
|
|
383
715
|
|
|
384
|
-
|
|
385
|
-
|
|
716
|
+
if (!skipHerokuLoginCheck) {
|
|
717
|
+
const canPublish = await ensureHerokuLoginOrSkip();
|
|
718
|
+
if (!canPublish) {
|
|
719
|
+
console.log("\nℹ️ Publicação cancelada. O projeto continua localmente sem deploy no Heroku.");
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
386
723
|
|
|
387
724
|
const manifest = await readManifest(projectDir);
|
|
725
|
+
if (!manifest) {
|
|
726
|
+
throw new Error(
|
|
727
|
+
`Não encontrei ${MANIFEST_NAME} em ${projectDir}. Cria o projeto com "abw-react-starter create <dir>" primeiro.`
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const region = (manifest.region || "eu").toLowerCase();
|
|
732
|
+
const backendDir = path.join(projectDir, "backend");
|
|
733
|
+
const frontendDir = path.join(projectDir, "frontend");
|
|
734
|
+
|
|
735
|
+
if (!(await fs.pathExists(backendDir))) throw new Error("Não encontrei a pasta backend.");
|
|
736
|
+
if (!(await fs.pathExists(frontendDir))) throw new Error("Não encontrei a pasta frontend.");
|
|
737
|
+
|
|
738
|
+
let backendApp = manifest.backendApp;
|
|
739
|
+
let frontendApp = manifest.frontendApp;
|
|
740
|
+
|
|
741
|
+
if (!backendApp || !frontendApp) {
|
|
742
|
+
const projectBase = path.basename(projectDir);
|
|
743
|
+
const a = await inquirer.prompt([
|
|
744
|
+
{
|
|
745
|
+
type: "input",
|
|
746
|
+
name: "backendApp",
|
|
747
|
+
message: "Nome da app Heroku do backend (Strapi):",
|
|
748
|
+
default: backendApp || `${projectBase}-api`,
|
|
749
|
+
validate: (v) => (v?.length >= 3 ? true : "Nome muito curto"),
|
|
750
|
+
},
|
|
751
|
+
{
|
|
752
|
+
type: "input",
|
|
753
|
+
name: "frontendApp",
|
|
754
|
+
message: "Nome da app Heroku do frontend (Next):",
|
|
755
|
+
default: frontendApp || `${projectBase}-web`,
|
|
756
|
+
validate: (v) => (v?.length >= 3 ? true : "Nome muito curto"),
|
|
757
|
+
},
|
|
758
|
+
]);
|
|
388
759
|
|
|
389
|
-
|
|
760
|
+
backendApp = a.backendApp;
|
|
761
|
+
frontendApp = a.frontendApp;
|
|
762
|
+
|
|
763
|
+
await writeManifest(projectDir, {
|
|
764
|
+
backendApp,
|
|
765
|
+
frontendApp,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const backendResult = await deployBackendHeroku({
|
|
390
770
|
projectDir,
|
|
391
|
-
backendApp
|
|
392
|
-
region
|
|
771
|
+
backendApp,
|
|
772
|
+
region,
|
|
393
773
|
});
|
|
394
774
|
|
|
395
|
-
const
|
|
775
|
+
const frontendResult = await deployFrontendHeroku({
|
|
396
776
|
projectDir,
|
|
397
|
-
frontendApp
|
|
398
|
-
region
|
|
399
|
-
backendWebUrl,
|
|
777
|
+
frontendApp,
|
|
778
|
+
region,
|
|
779
|
+
backendWebUrl: backendResult.backendWebUrl,
|
|
400
780
|
});
|
|
401
781
|
|
|
402
782
|
await writeManifest(projectDir, {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
783
|
+
deployedAt: new Date().toISOString(),
|
|
784
|
+
backendApp: backendResult.backendApp,
|
|
785
|
+
frontendApp: frontendResult.frontendApp,
|
|
786
|
+
backendUrl: backendResult.backendWebUrl,
|
|
787
|
+
frontendUrl: frontendResult.frontendWebUrl,
|
|
406
788
|
});
|
|
407
789
|
|
|
408
790
|
console.log("\n✅ Tudo pronto!");
|
|
409
|
-
console.log(
|
|
410
|
-
console.log(
|
|
791
|
+
console.log(`Backend app: ${backendResult.backendApp}`);
|
|
792
|
+
console.log(`Backend (Strapi): ${backendResult.backendWebUrl}`);
|
|
793
|
+
console.log(`Admin: ${backendResult.backendWebUrl}/admin`);
|
|
794
|
+
console.log(`Frontend app: ${frontendResult.frontendApp}`);
|
|
795
|
+
console.log(`Frontend (Next): ${frontendResult.frontendWebUrl}`);
|
|
796
|
+
console.log("\nNotas:");
|
|
797
|
+
console.log("- Content types normalmente crias localmente e depois fazes push para o backend Heroku.");
|
|
798
|
+
console.log("- Em produção, dá permissões em Public role para expor endpoints.");
|
|
411
799
|
}
|
|
412
800
|
|
|
413
801
|
/* =========================
|
|
414
|
-
CLI
|
|
802
|
+
CLI
|
|
415
803
|
========================= */
|
|
416
804
|
|
|
417
805
|
program.name("abw-react-starter");
|
|
418
806
|
|
|
807
|
+
// Comando create (default)
|
|
419
808
|
program
|
|
420
809
|
.command("create")
|
|
421
|
-
.argument("<projectDir>")
|
|
422
|
-
.option("--backend-app <name>")
|
|
423
|
-
.option("--frontend-app <name>")
|
|
424
|
-
.option("--node <version>", "20.x")
|
|
425
|
-
.option("--region <region>", "eu")
|
|
810
|
+
.argument("<projectDir>", "Diretório do projeto (será criado)")
|
|
811
|
+
.option("--backend-app <name>", "Nome da app Heroku do backend (Strapi)")
|
|
812
|
+
.option("--frontend-app <name>", "Nome da app Heroku do frontend (Next)")
|
|
813
|
+
.option("--node <version>", "Versão Node para Heroku (ex: 20.x)", "20.x")
|
|
814
|
+
.option("--region <region>", "Heroku region (eu | us)", "eu")
|
|
426
815
|
.action(async (projectDirArg, opts) => {
|
|
427
816
|
await doCreate(projectDirArg, opts);
|
|
428
817
|
});
|
|
429
818
|
|
|
819
|
+
// Para compatibilidade: se chamarem sem "create", trata como create
|
|
430
820
|
program
|
|
431
|
-
.argument("[projectDir]")
|
|
432
|
-
.option("--backend-app <name>")
|
|
433
|
-
.option("--frontend-app <name>")
|
|
434
|
-
.option("--node <version>", "20.x")
|
|
435
|
-
.option("--region <region>", "eu");
|
|
821
|
+
.argument("[projectDir]", "Diretório do projeto (será criado)")
|
|
822
|
+
.option("--backend-app <name>", "Nome da app Heroku do backend (Strapi)")
|
|
823
|
+
.option("--frontend-app <name>", "Nome da app Heroku do frontend (Next)")
|
|
824
|
+
.option("--node <version>", "Versão Node para Heroku (ex: 20.x)", "20.x")
|
|
825
|
+
.option("--region <region>", "Heroku region (eu | us)", "eu");
|
|
436
826
|
|
|
827
|
+
// Comando publish
|
|
437
828
|
program
|
|
438
829
|
.command("publish")
|
|
439
|
-
.argument("[projectDir]")
|
|
830
|
+
.argument("[projectDir]", "Diretório do projeto (default: .)")
|
|
831
|
+
.description("Publicar um projeto existente no Heroku")
|
|
440
832
|
.action(async (projectDirArg) => {
|
|
441
833
|
await doPublish(projectDirArg || ".");
|
|
442
834
|
});
|
|
443
835
|
|
|
836
|
+
// Se executarem sem subcomando, faz create
|
|
444
837
|
program.action(async (projectDirArg, opts) => {
|
|
445
|
-
if (!projectDirArg)
|
|
838
|
+
if (!projectDirArg) {
|
|
839
|
+
program.help();
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
446
842
|
await doCreate(projectDirArg, opts);
|
|
447
843
|
});
|
|
448
844
|
|
package/package.json
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "abw-react-starter",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "CLI para criar projetos Strapi + Next + Heroku",
|
|
5
5
|
"bin": {
|
|
6
6
|
"abw-react-starter": "./bin/create.js"
|
|
7
7
|
},
|
|
8
8
|
"type": "module",
|
|
9
9
|
"scripts": {},
|
|
10
|
-
"keywords": [
|
|
10
|
+
"keywords": [
|
|
11
|
+
"cli",
|
|
12
|
+
"strapi",
|
|
13
|
+
"nextjs",
|
|
14
|
+
"heroku"
|
|
15
|
+
],
|
|
11
16
|
"author": "ABWorks",
|
|
12
17
|
"license": "MIT",
|
|
13
18
|
"dependencies": {
|
|
@@ -16,4 +21,4 @@
|
|
|
16
21
|
"fs-extra": "^11.0.0",
|
|
17
22
|
"execa": "^8.0.0"
|
|
18
23
|
}
|
|
19
|
-
}
|
|
24
|
+
}
|