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.
Files changed (3) hide show
  1. package/README.md +175 -0
  2. package/bin/create.js +456 -60
  3. 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 fs.writeJson(path.join(projectDir, MANIFEST_NAME), data, {
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, ".env"),
187
- `AWS_ACCESS_KEY_ID=${aws.key}
188
- AWS_ACCESS_SECRET=${aws.secret}
189
- AWS_REGION=${aws.region}
190
- AWS_BUCKET=${aws.bucket}
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
- await run("heroku", ["create", backendApp, "--region", region], { cwd: beDir });
259
- await run("heroku", ["git:remote", "-a", backendApp], { cwd: beDir });
260
- await run("heroku", ["addons:create", "heroku-postgresql", "-a", backendApp], { cwd: beDir });
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(`${k}=(.*)`))?.[1]?.trim();
267
-
268
- await run("heroku", ["config:set", `AWS_BUCKET=${get("AWS_BUCKET")}`, "-a", backendApp], { cwd: beDir });
269
- await run("heroku", ["config:set", `AWS_REGION=${get("AWS_REGION")}`, "-a", backendApp], { cwd: beDir });
270
- await run("heroku", ["config:set", `AWS_ACCESS_KEY_ID=${get("AWS_ACCESS_KEY_ID")}`, "-a", backendApp], { cwd: beDir });
271
- await run("heroku", ["config:set", `AWS_ACCESS_SECRET=${get("AWS_ACCESS_SECRET")}`, "-a", backendApp], { cwd: beDir });
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 = JSON.parse(await runCapture("heroku", ["apps:info", "-a", backendApp, "--json"]));
277
- return ensureNoTrailingSlash(info?.app?.web_url);
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
- await run("heroku", ["create", frontendApp, "--region", region], { cwd: feDir });
288
- await run("heroku", ["git:remote", "-a", frontendApp], { cwd: feDir });
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
- await run("heroku", ["config:set", `NEXT_PUBLIC_STRAPI_URL=${backendWebUrl}`, "-a", frontendApp], {
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 = JSON.parse(await runCapture("heroku", ["apps:info", "-a", frontendApp, "--json"]));
297
- return ensureNoTrailingSlash(info?.app?.web_url);
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) await configureBackendS3(projectDir, aws);
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) return;
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
- await doPublish(projectDir);
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
- const canPublish = await ensureHerokuLoginOrSkip();
385
- if (!canPublish) return;
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
- const backendWebUrl = await deployBackendHeroku({
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: manifest.backendApp,
392
- region: manifest.region,
771
+ backendApp,
772
+ region,
393
773
  });
394
774
 
395
- const frontendWebUrl = await deployFrontendHeroku({
775
+ const frontendResult = await deployFrontendHeroku({
396
776
  projectDir,
397
- frontendApp: manifest.frontendApp,
398
- region: manifest.region,
399
- backendWebUrl,
777
+ frontendApp,
778
+ region,
779
+ backendWebUrl: backendResult.backendWebUrl,
400
780
  });
401
781
 
402
782
  await writeManifest(projectDir, {
403
- ...manifest,
404
- backendUrl: backendWebUrl,
405
- frontendUrl: frontendWebUrl,
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("Backend:", backendWebUrl);
410
- console.log("Frontend:", frontendWebUrl);
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 (INALTERADO)
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) return program.help();
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.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": ["cli", "strapi", "nextjs", "heroku"],
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
+ }