abw-react-starter 1.0.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.
Files changed (2) hide show
  1. package/bin/create.js +452 -0
  2. package/package.json +19 -0
package/bin/create.js ADDED
@@ -0,0 +1,452 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from "commander";
4
+ import inquirer from "inquirer";
5
+ import fs from "fs-extra";
6
+ import path from "path";
7
+ import { execa } from "execa";
8
+ import crypto from "crypto";
9
+
10
+ const program = new Command();
11
+ const MANIFEST_NAME = ".abw-starter.json";
12
+
13
+ /* =========================
14
+ Utils
15
+ ========================= */
16
+
17
+ function randHex(bytes = 16) {
18
+ return crypto.randomBytes(bytes).toString("hex");
19
+ }
20
+
21
+ async function run(cmd, args, opts = {}) {
22
+ const p = execa(cmd, args, {
23
+ stdio: "inherit",
24
+ ...opts,
25
+ });
26
+ await p;
27
+ }
28
+
29
+ async function runCapture(cmd, args, opts = {}) {
30
+ const p = await execa(cmd, args, { ...opts });
31
+ return p.stdout?.trim();
32
+ }
33
+
34
+ function ensureNoTrailingSlash(url) {
35
+ return (url || "").replace(/\/+$/, "");
36
+ }
37
+
38
+ async function assertCmdExists(cmd, args = ["--version"]) {
39
+ try {
40
+ await execa(cmd, args, { stdio: "ignore" });
41
+ } catch {
42
+ throw new Error(`Falta o comando "${cmd}" no PATH.`);
43
+ }
44
+ }
45
+
46
+ /* =========================
47
+ Heroku Login (melhorado)
48
+ ========================= */
49
+
50
+ async function isHerokuLoggedIn() {
51
+ try {
52
+ await execa("heroku", ["auth:whoami"], { stdio: "ignore" });
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ async function ensureHerokuLoginOrSkip() {
60
+ if (await isHerokuLoggedIn()) return true;
61
+
62
+ const { doLogin } = await inquirer.prompt([
63
+ {
64
+ type: "confirm",
65
+ name: "doLogin",
66
+ message: "Não estás autenticado no Heroku. Queres fazer login?",
67
+ default: true,
68
+ },
69
+ ]);
70
+
71
+ if (!doLogin) return false;
72
+
73
+ console.log("\n🔐 A abrir login do Heroku...\n");
74
+ await run("heroku", ["login"]);
75
+
76
+ if (!(await isHerokuLoggedIn())) {
77
+ throw new Error("Falha no login do Heroku.");
78
+ }
79
+
80
+ return true;
81
+ }
82
+
83
+ /* =========================
84
+ Manifest
85
+ ========================= */
86
+
87
+ async function writeManifest(projectDir, data) {
88
+ await fs.writeJson(path.join(projectDir, MANIFEST_NAME), data, {
89
+ spaces: 2,
90
+ });
91
+ }
92
+
93
+ async function readManifest(projectDir) {
94
+ const p = path.join(projectDir, MANIFEST_NAME);
95
+ if (!(await fs.pathExists(p))) return null;
96
+ return fs.readJson(p);
97
+ }
98
+
99
+ function resolveProjectDir(p) {
100
+ return path.resolve(process.cwd(), p || ".");
101
+ }
102
+
103
+ async function ensureEmptyDir(projectDir) {
104
+ if (await fs.pathExists(projectDir)) {
105
+ throw new Error(`A pasta já existe: ${projectDir}`);
106
+ }
107
+ }
108
+
109
+ /* =========================
110
+ Git
111
+ ========================= */
112
+
113
+ async function initGitRepo(cwd, message) {
114
+ await run("git", ["init"], { cwd });
115
+ await run("git", ["add", "."], { cwd });
116
+ await run("git", ["commit", "-m", message], { cwd });
117
+ await run("git", ["branch", "-M", "main"], { cwd });
118
+ }
119
+
120
+ /* =========================
121
+ 🆕 S3 Setup
122
+ ========================= */
123
+
124
+ async function configureBackendS3(projectDir, aws) {
125
+ const beDir = path.join(projectDir, "backend");
126
+
127
+ console.log("\n=== BACKEND: instalar AWS S3 provider ===\n");
128
+ await run("npm", ["i", "@strapi/provider-upload-aws-s3"], { cwd: beDir });
129
+
130
+ const bucketUrl = `https://${aws.bucket}.s3.${aws.region}.amazonaws.com`;
131
+
132
+ await fs.writeFile(
133
+ path.join(beDir, "config", "middlewares.ts"),
134
+ `export default [
135
+ 'strapi::logger',
136
+ 'strapi::errors',
137
+ {
138
+ name: 'strapi::security',
139
+ config: {
140
+ contentSecurityPolicy: {
141
+ useDefaults: true,
142
+ directives: {
143
+ 'img-src': ["'self'", 'data:', 'blob:', '${bucketUrl}'],
144
+ 'media-src': ["'self'", 'data:', 'blob:', '${bucketUrl}'],
145
+ },
146
+ },
147
+ },
148
+ },
149
+ 'strapi::cors',
150
+ 'strapi::poweredBy',
151
+ 'strapi::query',
152
+ 'strapi::body',
153
+ 'strapi::session',
154
+ 'strapi::favicon',
155
+ 'strapi::public',
156
+ ];
157
+ `
158
+ );
159
+
160
+ await fs.writeFile(
161
+ path.join(beDir, "config", "plugins.ts"),
162
+ `export default ({ env }) => ({
163
+ upload: {
164
+ config: {
165
+ provider: 'aws-s3',
166
+ providerOptions: {
167
+ s3Options: {
168
+ credentials: {
169
+ accessKeyId: env('AWS_ACCESS_KEY_ID'),
170
+ secretAccessKey: env('AWS_ACCESS_SECRET'),
171
+ },
172
+ region: env('AWS_REGION'),
173
+ params: {
174
+ Bucket: env('AWS_BUCKET'),
175
+ ACL: 'public-read',
176
+ },
177
+ },
178
+ },
179
+ },
180
+ },
181
+ });
182
+ `
183
+ );
184
+
185
+ 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}
191
+ `
192
+ );
193
+ }
194
+
195
+ /* =========================
196
+ Backend
197
+ ========================= */
198
+
199
+ async function createBackendLocal(projectDir) {
200
+ console.log("\n=== BACKEND: criar Strapi ===\n");
201
+ await run("npx", [
202
+ "create-strapi-app@latest",
203
+ "backend",
204
+ "--non-interactive",
205
+ "--skip-cloud",
206
+ "--no-run",
207
+ "--dbclient",
208
+ "sqlite",
209
+ "--dbfile",
210
+ ".tmp/data.db",
211
+ "--no-example",
212
+ "--typescript",
213
+ "--use-npm",
214
+ "--install",
215
+ "--no-git-init",
216
+ ], { cwd: projectDir });
217
+
218
+ console.log("\n=== BACKEND: instalar pg ===\n");
219
+ await run("npm", ["i", "pg"], {
220
+ cwd: path.join(projectDir, "backend"),
221
+ });
222
+
223
+ await initGitRepo(path.join(projectDir, "backend"), "Init Strapi backend");
224
+ }
225
+
226
+ /* =========================
227
+ Frontend
228
+ ========================= */
229
+
230
+ async function createFrontendLocal(projectDir, nodeVersion) {
231
+ console.log("\n=== FRONTEND: criar Next.js ===\n");
232
+ await run("npx", ["create-next-app@latest", "frontend"], {
233
+ cwd: projectDir,
234
+ });
235
+
236
+ const feDir = path.join(projectDir, "frontend");
237
+ const pkgPath = path.join(feDir, "package.json");
238
+ const pkg = await fs.readJson(pkgPath);
239
+
240
+ pkg.engines = pkg.engines || {};
241
+ pkg.engines.node = nodeVersion;
242
+
243
+ pkg.scripts = pkg.scripts || {};
244
+ pkg.scripts.start = "next start -p $PORT";
245
+
246
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
247
+
248
+ await initGitRepo(feDir, "Init Next frontend");
249
+ }
250
+
251
+ /* =========================
252
+ Deploy Backend
253
+ ========================= */
254
+
255
+ async function deployBackendHeroku({ projectDir, backendApp, region }) {
256
+ const beDir = path.join(projectDir, "backend");
257
+
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 });
261
+
262
+ const envPath = path.join(beDir, ".env");
263
+
264
+ if (await fs.pathExists(envPath)) {
265
+ 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 });
272
+ }
273
+
274
+ await run("git", ["push", "heroku", "main"], { cwd: beDir });
275
+
276
+ const info = JSON.parse(await runCapture("heroku", ["apps:info", "-a", backendApp, "--json"]));
277
+ return ensureNoTrailingSlash(info?.app?.web_url);
278
+ }
279
+
280
+ /* =========================
281
+ Deploy Frontend
282
+ ========================= */
283
+
284
+ async function deployFrontendHeroku({ projectDir, frontendApp, region, backendWebUrl }) {
285
+ const feDir = path.join(projectDir, "frontend");
286
+
287
+ await run("heroku", ["create", frontendApp, "--region", region], { cwd: feDir });
288
+ await run("heroku", ["git:remote", "-a", frontendApp], { cwd: feDir });
289
+
290
+ await run("heroku", ["config:set", `NEXT_PUBLIC_STRAPI_URL=${backendWebUrl}`, "-a", frontendApp], {
291
+ cwd: feDir,
292
+ });
293
+
294
+ await run("git", ["push", "heroku", "main"], { cwd: feDir });
295
+
296
+ const info = JSON.parse(await runCapture("heroku", ["apps:info", "-a", frontendApp, "--json"]));
297
+ return ensureNoTrailingSlash(info?.app?.web_url);
298
+ }
299
+
300
+ /* =========================
301
+ Create
302
+ ========================= */
303
+
304
+ async function doCreate(projectDirArg, opts) {
305
+ await assertCmdExists("git");
306
+ await assertCmdExists("node");
307
+ await assertCmdExists("npm");
308
+
309
+ const projectDir = resolveProjectDir(projectDirArg);
310
+ await ensureEmptyDir(projectDir);
311
+
312
+ const projectBase = path.basename(projectDir);
313
+ const region = (opts.region || "eu").toLowerCase();
314
+ const nodeVersion = opts.node || "20.x";
315
+
316
+ // S3
317
+ const { useS3 } = await inquirer.prompt([
318
+ { type: "confirm", name: "useS3", message: "Usar S3?", default: true },
319
+ ]);
320
+
321
+ let aws = null;
322
+
323
+ if (useS3) {
324
+ aws = await inquirer.prompt([
325
+ { name: "bucket", message: "Bucket:" },
326
+ { name: "region", default: "eu-north-1" },
327
+ { name: "key", message: "Access Key:" },
328
+ { type: "password", name: "secret", message: "Secret:" },
329
+ ]);
330
+ }
331
+
332
+ const { deployNow } = await inquirer.prompt([
333
+ {
334
+ type: "confirm",
335
+ name: "deployNow",
336
+ message: "Queres publicar no Heroku agora?",
337
+ default: true,
338
+ },
339
+ ]);
340
+
341
+ let backendApp = opts.backendApp || `${projectBase}-api`;
342
+ let frontendApp = opts.frontendApp || `${projectBase}-web`;
343
+
344
+ await fs.ensureDir(projectDir);
345
+ await fs.ensureDir(path.join(projectDir, "backend"));
346
+ await fs.ensureDir(path.join(projectDir, "frontend"));
347
+
348
+ await createBackendLocal(projectDir);
349
+
350
+ if (useS3) await configureBackendS3(projectDir, aws);
351
+
352
+ await createFrontendLocal(projectDir, nodeVersion);
353
+
354
+ await writeManifest(projectDir, {
355
+ version: 1,
356
+ region,
357
+ nodeVersion,
358
+ backendApp,
359
+ frontendApp,
360
+ useS3,
361
+ awsBucket: aws?.bucket,
362
+ awsRegion: aws?.region,
363
+ createdAt: new Date().toISOString(),
364
+ });
365
+
366
+ if (!deployNow) return;
367
+
368
+ const canPublish = await ensureHerokuLoginOrSkip();
369
+ if (!canPublish) return;
370
+
371
+ await doPublish(projectDir);
372
+ }
373
+
374
+ /* =========================
375
+ Publish
376
+ ========================= */
377
+
378
+ async function doPublish(projectDirArg) {
379
+ const projectDir = resolveProjectDir(projectDirArg);
380
+
381
+ await assertCmdExists("git");
382
+ await assertCmdExists("heroku");
383
+
384
+ const canPublish = await ensureHerokuLoginOrSkip();
385
+ if (!canPublish) return;
386
+
387
+ const manifest = await readManifest(projectDir);
388
+
389
+ const backendWebUrl = await deployBackendHeroku({
390
+ projectDir,
391
+ backendApp: manifest.backendApp,
392
+ region: manifest.region,
393
+ });
394
+
395
+ const frontendWebUrl = await deployFrontendHeroku({
396
+ projectDir,
397
+ frontendApp: manifest.frontendApp,
398
+ region: manifest.region,
399
+ backendWebUrl,
400
+ });
401
+
402
+ await writeManifest(projectDir, {
403
+ ...manifest,
404
+ backendUrl: backendWebUrl,
405
+ frontendUrl: frontendWebUrl,
406
+ });
407
+
408
+ console.log("\n✅ Tudo pronto!");
409
+ console.log("Backend:", backendWebUrl);
410
+ console.log("Frontend:", frontendWebUrl);
411
+ }
412
+
413
+ /* =========================
414
+ CLI (INALTERADO)
415
+ ========================= */
416
+
417
+ program.name("abw-react-starter");
418
+
419
+ program
420
+ .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")
426
+ .action(async (projectDirArg, opts) => {
427
+ await doCreate(projectDirArg, opts);
428
+ });
429
+
430
+ 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");
436
+
437
+ program
438
+ .command("publish")
439
+ .argument("[projectDir]")
440
+ .action(async (projectDirArg) => {
441
+ await doPublish(projectDirArg || ".");
442
+ });
443
+
444
+ program.action(async (projectDirArg, opts) => {
445
+ if (!projectDirArg) return program.help();
446
+ await doCreate(projectDirArg, opts);
447
+ });
448
+
449
+ program.parseAsync(process.argv).catch((err) => {
450
+ console.error("\n❌ Erro:", err?.message || err);
451
+ process.exit(1);
452
+ });
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "abw-react-starter",
3
+ "version": "1.0.0",
4
+ "description": "CLI para criar projetos Strapi + Next + Heroku",
5
+ "bin": {
6
+ "abw-react-starter": "./bin/create.js"
7
+ },
8
+ "type": "module",
9
+ "scripts": {},
10
+ "keywords": ["cli", "strapi", "nextjs", "heroku"],
11
+ "author": "ABWorks",
12
+ "license": "MIT",
13
+ "dependencies": {
14
+ "commander": "^12.0.0",
15
+ "inquirer": "^9.0.0",
16
+ "fs-extra": "^11.0.0",
17
+ "execa": "^8.0.0"
18
+ }
19
+ }