create-asaje-go-vue 0.3.7 → 0.3.9

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.
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import crypto from "node:crypto";
4
3
  import os from "node:os";
5
4
  import path from "node:path";
6
5
  import process from "node:process";
@@ -8,63 +7,100 @@ import {
8
7
  cancel,
9
8
  confirm,
10
9
  intro,
11
- isCancel,
12
10
  outro,
13
- password,
14
11
  select,
15
12
  text,
16
13
  } from "@clack/prompts";
17
- import degit from "degit";
18
14
  import { execa } from "execa";
19
15
  import fs from "fs-extra";
20
16
  import pc from "picocolors";
17
+ import {
18
+ parseCreateArgs,
19
+ parseDeployRailwayArgs,
20
+ parseDestroyRailwayArgs,
21
+ parseDirectoryArgs,
22
+ parseSetupRailwayArgs,
23
+ parseStartArgs,
24
+ parseUpdateArgs,
25
+ } from "../src/cli/args.js";
26
+ import { toEnvContent, tryReadEnvFile } from "../src/cli/env.js";
27
+ import { splitCommaSeparatedPaths, uniquePaths } from "../src/cli/paths.js";
28
+ import { prompt } from "../src/cli/prompts.js";
29
+ import { checkCommand, createManagedProcess, runCommand } from "../src/cli/process.js";
30
+ import { runCliCommand } from "../src/cli/runner.js";
31
+ import {
32
+ randomSecret,
33
+ resolveProjectSlug,
34
+ shellEscape,
35
+ } from "../src/cli/strings.js";
36
+ import { fetchRailwayProjectServices } from "../src/railway/client.js";
37
+ import {
38
+ readRailwayManifest as readRailwayManifestFile,
39
+ writeRailwayManifest as writeRailwayManifestFile,
40
+ } from "../src/railway/manifest.js";
41
+ import {
42
+ DEFAULT_RAILWAY_APP_SERVICE_SPECS,
43
+ buildCreateRailwayServices,
44
+ buildSyncedRailwayManifest,
45
+ findCreatedRailwayService,
46
+ findRailwayService,
47
+ findRailwayServiceByKey,
48
+ normalizeRailwayServiceName,
49
+ normalizeRailwayServices,
50
+ resolveRailwayAppServiceSpecs,
51
+ resolveRailwayServiceName,
52
+ updateRailwayManifestAppServices,
53
+ } from "../src/railway/services.js";
54
+ import {
55
+ buildGithubWorkflowContent,
56
+ buildProjectReadmeContent,
57
+ buildReadmeAnswersFromProjectConfig,
58
+ getGithubActionsDeployConfig,
59
+ } from "../src/project/docs.js";
60
+ import { buildProjectMakefileContent } from "../src/project/makefile.js";
61
+ import {
62
+ cleanupTemplateFiles,
63
+ cloneTemplate,
64
+ ensureDestinationIsAvailable,
65
+ ensureProjectStructure,
66
+ ensureScaffoldedSurfaces,
67
+ loadProjectConfig,
68
+ } from "../src/project/files.js";
69
+ import {
70
+ buildSyncedProjectConfig,
71
+ getRailwayConfig,
72
+ scanProjectForManagedRailwayServices,
73
+ } from "../src/project/sync.js";
74
+ import { collectCreateAnswers } from "../src/create/questions.js";
75
+ import { collectStartAnswers } from "../src/start/answers.js";
76
+ import {
77
+ ensureEnvFiles,
78
+ installProjectDependencies,
79
+ loadRuntimeConfig,
80
+ startInfrastructure,
81
+ } from "../src/start/runtime.js";
82
+ import {
83
+ buildRailwayConfigExportFilename,
84
+ buildRailwayConfigSnapshotDiff,
85
+ readRailwayConfigSnapshot,
86
+ sanitizeRailwayConfigSnapshotDiff,
87
+ assertRailwaySnapshotImportable,
88
+ } from "../src/railway/snapshot.js";
89
+ import {
90
+ assignManagedRailwayServiceVariables,
91
+ buildRailwayBrowserOrigins,
92
+ buildRailwayVariableDiff,
93
+ formatRailwayVariableValue,
94
+ sanitizeVariablesForOutput,
95
+ } from "../src/railway/variables.js";
96
+ import { resolveInvocation } from "../src/cli/invocation.js";
21
97
 
22
98
  const DEFAULT_TEMPLATE = process.env.ASAJE_TEMPLATE_REPO || "asaje379/boilerplate-go-vue";
23
99
  const DEFAULT_BRANCH = process.env.ASAJE_TEMPLATE_BRANCH || "main";
24
- const EXCLUDED_TEMPLATE_PATHS = ["cli"];
25
- const RAILWAY_GRAPHQL_ENDPOINT = "https://backboard.railway.com/graphql/v2";
26
100
  const RAILWAY_MANIFEST_FILENAME = "asaje.railway.json";
27
101
  const DEFAULT_RAILWAY_BUCKET = "boilerplate-files";
28
102
  const RAILWAY_SERVICE_DISCOVERY_RETRY_DELAY_MS = 2000;
29
103
  const RAILWAY_SERVICE_DISCOVERY_RETRY_COUNT = 5;
30
- const DEFAULT_RAILWAY_APP_SERVICE_SPECS = [
31
- {
32
- aliases: ["api", "backend", "server"],
33
- baseName: "api",
34
- directory: "api",
35
- key: "api",
36
- },
37
- {
38
- aliases: ["worker", "api-worker"],
39
- baseName: "worker",
40
- directory: "api",
41
- key: "worker",
42
- },
43
- {
44
- aliases: ["admin", "frontend", "web"],
45
- baseName: "admin",
46
- directory: "admin",
47
- key: "admin",
48
- },
49
- {
50
- aliases: ["realtime-gateway", "realtime"],
51
- baseName: "realtime-gateway",
52
- directory: "realtime-gateway",
53
- key: "realtime",
54
- },
55
- {
56
- aliases: ["landing", "marketing"],
57
- baseName: "landing",
58
- directory: "landing",
59
- key: "landing",
60
- },
61
- {
62
- aliases: ["pwa", "mobile-web"],
63
- baseName: "pwa",
64
- directory: "pwa",
65
- key: "pwa",
66
- },
67
- ];
68
104
  const SAFE_UPDATE_PATHS = [
69
105
  ".github/workflows/deploy-railway.yml",
70
106
  "docker-compose.yml",
@@ -87,13 +123,6 @@ const SAFE_UPDATE_PATHS = [
87
123
  "pwa/nginx.conf",
88
124
  "pwa/public",
89
125
  ];
90
- const ENV_FILE_SPECS = [
91
- { envPath: "admin/.env", examplePath: "admin/.env.example" },
92
- { envPath: "api/.env", examplePath: "api/.env.example" },
93
- { envPath: "realtime-gateway/.env", examplePath: "realtime-gateway/.env.example" },
94
- { envPath: "landing/.env", examplePath: "landing/.env.example" },
95
- { envPath: "pwa/.env", examplePath: "pwa/.env.example" },
96
- ];
97
126
 
98
127
  await main();
99
128
 
@@ -102,110 +131,27 @@ async function main() {
102
131
  intro(pc.cyan(invocation.title));
103
132
 
104
133
  try {
105
- if (invocation.command === "help") {
106
- printHelp();
107
- outro(pc.green("Ready."));
108
- return;
109
- }
110
-
111
- if (invocation.command === "start") {
112
- await runStart(invocation.argv);
113
- outro(pc.green("Services stopped."));
114
- return;
115
- }
116
-
117
- if (invocation.command === "doctor") {
118
- await runDoctor(invocation.argv);
119
- outro(pc.green("Doctor finished."));
120
- return;
121
- }
122
-
123
- if (invocation.command === "publish") {
124
- await runPublish(invocation.argv);
125
- outro(pc.green("Publish checklist complete."));
126
- return;
127
- }
128
-
129
- if (invocation.command === "update") {
130
- await runUpdate(invocation.argv);
131
- outro(pc.green("Project update complete."));
132
- return;
133
- }
134
-
135
- if (invocation.command === "sync-project-config") {
136
- await runSyncProjectConfig(invocation.argv);
137
- outro(pc.green("Project config sync complete."));
138
- return;
139
- }
140
-
141
- if (invocation.command === "sync-readme") {
142
- await runSyncReadme(invocation.argv);
143
- outro(pc.green("Project README sync complete."));
144
- return;
145
- }
146
-
147
- if (invocation.command === "sync-github-workflow") {
148
- await runSyncGithubWorkflow(invocation.argv);
149
- outro(pc.green("GitHub workflow sync complete."));
150
- return;
151
- }
152
-
153
- if (invocation.command === "setup-railway") {
154
- await runSetupRailway(invocation.argv);
155
- outro(pc.green("Railway setup complete."));
156
- return;
157
- }
158
-
159
- if (invocation.command === "update-railway") {
160
- await runUpdateRailway(invocation.argv);
161
- outro(pc.green("Railway update complete."));
162
- return;
163
- }
164
-
165
- if (invocation.command === "sync-railway-env") {
166
- await runSyncRailwayEnv(invocation.argv);
167
- outro(pc.green("Railway environment sync complete."));
168
- return;
169
- }
170
-
171
- if (invocation.command === "print-railway-config") {
172
- await runPrintRailwayConfig(invocation.argv);
173
- outro(pc.green("Railway config printed."));
174
- return;
175
- }
176
-
177
- if (invocation.command === "export-railway-config") {
178
- await runExportRailwayConfig(invocation.argv);
179
- outro(pc.green("Railway config exported."));
180
- return;
181
- }
182
-
183
- if (invocation.command === "import-railway-config") {
184
- await runImportRailwayConfig(invocation.argv);
185
- outro(pc.green("Railway config imported."));
186
- return;
187
- }
188
-
189
- if (invocation.command === "diff-railway-config") {
190
- await runDiffRailwayConfig(invocation.argv);
191
- outro(pc.green("Railway config diff complete."));
192
- return;
193
- }
194
-
195
- if (invocation.command === "deploy-railway") {
196
- await runDeployRailway(invocation.argv);
197
- outro(pc.green("Railway deployment complete."));
198
- return;
199
- }
200
-
201
- if (invocation.command === "destroy-railway") {
202
- await runDestroyRailway(invocation.argv);
203
- outro(pc.green("Railway teardown complete."));
204
- return;
205
- }
206
-
207
- await runCreate(invocation.argv);
208
- outro(pc.green("Project ready."));
134
+ const completionMessage = await runCliCommand(invocation, {
135
+ printHelp,
136
+ runCreate,
137
+ runDeployRailway,
138
+ runDestroyRailway,
139
+ runDiffRailwayConfig,
140
+ runDoctor,
141
+ runExportRailwayConfig,
142
+ runImportRailwayConfig,
143
+ runPrintRailwayConfig,
144
+ runPublish,
145
+ runSetupRailway,
146
+ runStart,
147
+ runSyncGithubWorkflow,
148
+ runSyncProjectConfig,
149
+ runSyncRailwayEnv,
150
+ runSyncReadme,
151
+ runUpdate,
152
+ runUpdateRailway,
153
+ });
154
+ outro(pc.green(completionMessage));
209
155
  } catch (error) {
210
156
  if (error instanceof Error) {
211
157
  cancel(error.message);
@@ -217,159 +163,6 @@ async function main() {
217
163
  }
218
164
  }
219
165
 
220
- function resolveInvocation(argv) {
221
- const binName = path.basename(argv[1] || "create-asaje-go-vue");
222
- const normalizedBinName = binName.replace(/\.js$/, "");
223
- const rawArgs = argv.slice(2);
224
- const firstArg = rawArgs[0];
225
-
226
- if (["help", "--help", "-h"].includes(firstArg || "")) {
227
- return { argv: rawArgs.slice(1), command: "help", title: normalizedBinName };
228
- }
229
-
230
- if (normalizedBinName === "asaje") {
231
- if (!firstArg) {
232
- return { argv: [], command: "help", title: "asaje" };
233
- }
234
-
235
- if (firstArg === "start") {
236
- return { argv: rawArgs.slice(1), command: "start", title: "asaje start" };
237
- }
238
-
239
- if (firstArg === "doctor") {
240
- return { argv: rawArgs.slice(1), command: "doctor", title: "asaje doctor" };
241
- }
242
-
243
- if (firstArg === "publish") {
244
- return { argv: rawArgs.slice(1), command: "publish", title: "asaje publish" };
245
- }
246
-
247
- if (firstArg === "update") {
248
- return { argv: rawArgs.slice(1), command: "update", title: "asaje update" };
249
- }
250
-
251
- if (firstArg === "sync-project-config") {
252
- return { argv: rawArgs.slice(1), command: "sync-project-config", title: "asaje sync-project-config" };
253
- }
254
-
255
- if (firstArg === "sync-readme") {
256
- return { argv: rawArgs.slice(1), command: "sync-readme", title: "asaje sync-readme" };
257
- }
258
-
259
- if (firstArg === "sync-github-workflow") {
260
- return { argv: rawArgs.slice(1), command: "sync-github-workflow", title: "asaje sync-github-workflow" };
261
- }
262
-
263
- if (firstArg === "setup-railway") {
264
- return { argv: rawArgs.slice(1), command: "setup-railway", title: "asaje setup-railway" };
265
- }
266
-
267
- if (firstArg === "update-railway") {
268
- return { argv: rawArgs.slice(1), command: "update-railway", title: "asaje update-railway" };
269
- }
270
-
271
- if (firstArg === "sync-railway-env") {
272
- return { argv: rawArgs.slice(1), command: "sync-railway-env", title: "asaje sync-railway-env" };
273
- }
274
-
275
- if (firstArg === "print-railway-config") {
276
- return { argv: rawArgs.slice(1), command: "print-railway-config", title: "asaje print-railway-config" };
277
- }
278
-
279
- if (firstArg === "export-railway-config") {
280
- return { argv: rawArgs.slice(1), command: "export-railway-config", title: "asaje export-railway-config" };
281
- }
282
-
283
- if (firstArg === "import-railway-config") {
284
- return { argv: rawArgs.slice(1), command: "import-railway-config", title: "asaje import-railway-config" };
285
- }
286
-
287
- if (firstArg === "diff-railway-config") {
288
- return { argv: rawArgs.slice(1), command: "diff-railway-config", title: "asaje diff-railway-config" };
289
- }
290
-
291
- if (firstArg === "deploy-railway") {
292
- return { argv: rawArgs.slice(1), command: "deploy-railway", title: "asaje deploy-railway" };
293
- }
294
-
295
- if (firstArg === "destroy-railway") {
296
- return { argv: rawArgs.slice(1), command: "destroy-railway", title: "asaje destroy-railway" };
297
- }
298
-
299
- if (firstArg === "create") {
300
- return { argv: rawArgs.slice(1), command: "create", title: "asaje create" };
301
- }
302
-
303
- return { argv: [], command: "help", title: "asaje" };
304
- }
305
-
306
- if (firstArg === "start") {
307
- return { argv: rawArgs.slice(1), command: "start", title: "create-asaje-go-vue" };
308
- }
309
-
310
- if (firstArg === "doctor") {
311
- return { argv: rawArgs.slice(1), command: "doctor", title: "create-asaje-go-vue" };
312
- }
313
-
314
- if (firstArg === "publish") {
315
- return { argv: rawArgs.slice(1), command: "publish", title: "create-asaje-go-vue" };
316
- }
317
-
318
- if (firstArg === "update") {
319
- return { argv: rawArgs.slice(1), command: "update", title: "create-asaje-go-vue" };
320
- }
321
-
322
- if (firstArg === "sync-project-config") {
323
- return { argv: rawArgs.slice(1), command: "sync-project-config", title: "create-asaje-go-vue" };
324
- }
325
-
326
- if (firstArg === "sync-readme") {
327
- return { argv: rawArgs.slice(1), command: "sync-readme", title: "create-asaje-go-vue" };
328
- }
329
-
330
- if (firstArg === "sync-github-workflow") {
331
- return { argv: rawArgs.slice(1), command: "sync-github-workflow", title: "create-asaje-go-vue" };
332
- }
333
-
334
- if (firstArg === "setup-railway") {
335
- return { argv: rawArgs.slice(1), command: "setup-railway", title: "create-asaje-go-vue" };
336
- }
337
-
338
- if (firstArg === "update-railway") {
339
- return { argv: rawArgs.slice(1), command: "update-railway", title: "create-asaje-go-vue" };
340
- }
341
-
342
- if (firstArg === "sync-railway-env") {
343
- return { argv: rawArgs.slice(1), command: "sync-railway-env", title: "create-asaje-go-vue" };
344
- }
345
-
346
- if (firstArg === "print-railway-config") {
347
- return { argv: rawArgs.slice(1), command: "print-railway-config", title: "create-asaje-go-vue" };
348
- }
349
-
350
- if (firstArg === "export-railway-config") {
351
- return { argv: rawArgs.slice(1), command: "export-railway-config", title: "create-asaje-go-vue" };
352
- }
353
-
354
- if (firstArg === "import-railway-config") {
355
- return { argv: rawArgs.slice(1), command: "import-railway-config", title: "create-asaje-go-vue" };
356
- }
357
-
358
- if (firstArg === "diff-railway-config") {
359
- return { argv: rawArgs.slice(1), command: "diff-railway-config", title: "create-asaje-go-vue" };
360
- }
361
-
362
- if (firstArg === "deploy-railway") {
363
- return { argv: rawArgs.slice(1), command: "deploy-railway", title: "create-asaje-go-vue" };
364
- }
365
-
366
- if (firstArg === "destroy-railway") {
367
- return { argv: rawArgs.slice(1), command: "destroy-railway", title: "create-asaje-go-vue" };
368
- }
369
-
370
- return { argv: rawArgs, command: "create", title: "create-asaje-go-vue" };
371
- }
372
-
373
166
  function printHelp() {
374
167
  console.log(pc.bold("\nCommands"));
375
168
  console.log(`- ${pc.bold("create-asaje-go-vue <directory>")} scaffold a new project`);
@@ -412,574 +205,33 @@ function printHelp() {
412
205
  }
413
206
 
414
207
  async function runCreate(argv) {
415
- const args = parseCreateArgs(argv);
208
+ const args = parseCreateArgs(argv, { defaultBranch: DEFAULT_BRANCH, defaultTemplate: DEFAULT_TEMPLATE });
416
209
  const answers = await collectCreateAnswers(args);
417
- const destinationDir = path.resolve(process.cwd(), answers.directory);
418
-
419
- await ensureDestinationIsAvailable(destinationDir);
420
-
421
- console.log(pc.dim("\nScaffolding project from GitHub template..."));
422
- await cloneTemplate(answers.template, answers.branch, destinationDir);
423
- await cleanupTemplateFiles(destinationDir);
424
- await writeProjectConfig(destinationDir, answers);
425
- await writeEnvFiles(destinationDir, answers);
426
- await writeProjectReadme(destinationDir, answers);
427
- await writeGithubWorkflow(destinationDir, answers);
428
-
429
- if (answers.installDependencies) {
430
- console.log(pc.dim("\nInstalling frontend and Go dependencies..."));
431
- await installProjectDependencies(destinationDir);
432
- }
433
-
434
- if (answers.startInfra) {
435
- console.log(pc.dim("\nStarting local infrastructure with Docker Compose..."));
436
- await startInfrastructure(destinationDir);
437
- }
438
-
439
- printCreateSummary(destinationDir, answers);
440
- }
441
-
442
- function parseCreateArgs(argv) {
443
- const options = {
444
- branch: DEFAULT_BRANCH,
445
- installDependencies: undefined,
446
- startInfra: undefined,
447
- template: DEFAULT_TEMPLATE,
448
- yes: false,
449
- };
450
- const positionals = [];
451
-
452
- for (let index = 0; index < argv.length; index += 1) {
453
- const arg = argv[index];
454
-
455
- if (arg === "--yes" || arg === "-y") {
456
- options.yes = true;
457
- continue;
458
- }
459
-
460
- if (arg === "--template") {
461
- options.template = argv[index + 1] || options.template;
462
- index += 1;
463
- continue;
464
- }
465
-
466
- if (arg.startsWith("--template=")) {
467
- options.template = arg.split("=")[1] || options.template;
468
- continue;
469
- }
470
-
471
- if (arg === "--branch") {
472
- options.branch = argv[index + 1] || options.branch;
473
- index += 1;
474
- continue;
475
- }
476
-
477
- if (arg.startsWith("--branch=")) {
478
- options.branch = arg.split("=")[1] || options.branch;
479
- continue;
480
- }
481
-
482
- if (arg === "--install") {
483
- options.installDependencies = true;
484
- continue;
485
- }
486
-
487
- if (arg === "--skip-install") {
488
- options.installDependencies = false;
489
- continue;
490
- }
491
-
492
- if (arg === "--start-infra") {
493
- options.startInfra = true;
494
- continue;
495
- }
496
-
497
- if (arg === "--skip-infra") {
498
- options.startInfra = false;
499
- continue;
500
- }
501
-
502
- positionals.push(arg);
503
- }
504
-
505
- return { ...options, directory: positionals[0] };
506
- }
507
-
508
- function parseUpdateArgs(argv) {
509
- const options = {
510
- branch: undefined,
511
- directory: ".",
512
- dryRun: false,
513
- include: [],
514
- template: undefined,
515
- yes: false,
516
- };
517
- const positionals = [];
518
-
519
- for (let index = 0; index < argv.length; index += 1) {
520
- const arg = argv[index];
521
-
522
- if (arg === "--yes" || arg === "-y") {
523
- options.yes = true;
524
- continue;
525
- }
526
-
527
- if (arg === "--dry-run") {
528
- options.dryRun = true;
529
- continue;
530
- }
531
-
532
- if (arg === "--template") {
533
- options.template = argv[index + 1] || options.template;
534
- index += 1;
535
- continue;
536
- }
537
-
538
- if (arg.startsWith("--template=")) {
539
- options.template = arg.split("=")[1] || options.template;
540
- continue;
541
- }
542
-
543
- if (arg === "--branch") {
544
- options.branch = argv[index + 1] || options.branch;
545
- index += 1;
546
- continue;
547
- }
548
-
549
- if (arg.startsWith("--branch=")) {
550
- options.branch = arg.split("=")[1] || options.branch;
551
- continue;
552
- }
553
-
554
- if (arg === "--include") {
555
- options.include.push(...splitCommaSeparatedPaths(argv[index + 1] || ""));
556
- index += 1;
557
- continue;
558
- }
559
-
560
- if (arg.startsWith("--include=")) {
561
- options.include.push(...splitCommaSeparatedPaths(arg.split("=")[1] || ""));
562
- continue;
563
- }
564
-
565
- positionals.push(arg);
566
- }
567
-
568
- options.directory = positionals[0] || options.directory;
569
- options.include = uniquePaths(options.include);
570
- return options;
571
- }
572
-
573
- async function collectCreateAnswers(args) {
574
- const defaultDirectory = args.directory || "my-asaje-app";
575
- const defaultSlug = slugify(path.basename(defaultDirectory));
576
- const defaultAppName = titleize(defaultSlug || "asaje app");
577
-
578
- if (args.yes) {
579
- return buildCreateAnswers({
580
- adminPort: 5173,
581
- apiPort: 8080,
582
- appName: defaultAppName,
583
- branch: args.branch,
584
- bucketBasePath: defaultSlug,
585
- corsAllowedOrigins: "",
586
- defaultLocale: "fr",
587
- directory: defaultDirectory,
588
- includeLanding: true,
589
- includePwa: true,
590
- installDependencies: args.installDependencies ?? true,
591
- mailFromEmail: `no-reply@${defaultSlug || "asaje-app"}.local`,
592
- mailFromName: defaultAppName,
593
- mailProvider: "mailchimp",
594
- mailchimpApiKey: "dev-placeholder-key",
595
- realtimePort: 8090,
596
- seedAdmin: false,
597
- seedUser: false,
598
- startInfra: args.startInfra ?? true,
599
- storageProvider: "minio",
600
- swaggerUsername: "swagger",
601
- template: args.template,
602
- });
603
- }
604
-
605
- const directory = await prompt(
606
- text({
607
- defaultValue: defaultDirectory,
608
- message: "Project directory?",
609
- placeholder: "my-asaje-app",
610
- validate(value) {
611
- return value.trim().length === 0 ? "Directory is required" : undefined;
612
- },
613
- }),
614
- );
615
-
616
- const appName = await prompt(
617
- text({
618
- defaultValue: defaultAppName,
619
- message: "App name?",
620
- placeholder: "My Asaje App",
621
- validate(value) {
622
- return value.trim().length === 0 ? "App name is required" : undefined;
623
- },
624
- }),
625
- );
626
-
627
- const defaultLocale = await prompt(
628
- select({
629
- initialValue: "fr",
630
- message: "Default locale?",
631
- options: [
632
- { label: "French", value: "fr" },
633
- { label: "English", value: "en" },
634
- ],
635
- }),
636
- );
637
-
638
- const adminPort = await promptNumber("Admin dev server port?", 5173);
639
- const apiPort = await promptNumber("API port?", 8080);
640
- const realtimePort = await promptNumber("Realtime gateway port?", 8090);
641
- const swaggerUsername = await prompt(
642
- text({
643
- defaultValue: "swagger",
644
- message: "Swagger username?",
645
- validate(value) {
646
- return value.trim().length === 0 ? "Swagger username is required" : undefined;
647
- },
648
- }),
649
- );
650
-
651
- const seedAdmin = await prompt(
652
- confirm({
653
- initialValue: true,
654
- message: "Seed an admin account now?",
655
- }),
656
- );
657
-
658
- let adminName = "Admin";
659
- let adminEmail = "admin@example.com";
660
- let adminPassword = "admin12345";
661
-
662
- if (seedAdmin) {
663
- adminName = await prompt(
664
- text({
665
- defaultValue: adminName,
666
- message: "Admin name?",
667
- validate(value) {
668
- return value.trim().length >= 2 ? undefined : "Admin name must be at least 2 characters";
669
- },
670
- }),
671
- );
672
-
673
- adminEmail = await promptEmail("Admin email?", adminEmail);
674
- adminPassword = await promptSecret("Admin password?", 8);
675
- }
676
-
677
- const seedUser = await prompt(
678
- confirm({
679
- initialValue: false,
680
- message: "Seed a standard user too?",
681
- }),
682
- );
683
-
684
- let seedUserName = "User";
685
- let seedUserEmail = "user@example.com";
686
- let seedUserPassword = "user12345";
687
-
688
- if (seedUser) {
689
- seedUserName = await prompt(
690
- text({
691
- defaultValue: seedUserName,
692
- message: "User name?",
693
- validate(value) {
694
- return value.trim().length >= 2 ? undefined : "User name must be at least 2 characters";
695
- },
696
- }),
697
- );
698
-
699
- seedUserEmail = await promptEmail("User email?", seedUserEmail);
700
- seedUserPassword = await promptSecret("User password?", 8);
701
- }
702
-
703
- const storageProvider = await prompt(
704
- select({
705
- initialValue: "minio",
706
- message: "Object storage provider?",
707
- options: [
708
- { label: "Local MinIO", value: "minio" },
709
- { label: "AWS S3 / compatible", value: "aws" },
710
- ],
711
- }),
712
- );
713
-
714
- const slug = slugify(path.basename(directory));
715
- const bucketBasePath = await prompt(
716
- text({
717
- defaultValue: slug,
718
- message: "Bucket base path?",
719
- placeholder: slug,
720
- }),
721
- );
722
-
723
- let minioEndpoint = "localhost";
724
- let minioPort = 9000;
725
- let minioUseSSL = false;
726
- let minioAccessKey = "minioadmin";
727
- let minioSecretKey = "minioadmin";
728
- let minioBucket = "boilerplate-files";
729
- let minioPublicURL = "http://localhost:9000";
730
- let awsEndpointURL = "";
731
- let awsAccessKeyId = "";
732
- let awsSecretAccessKey = "";
733
- let awsBucket = "";
734
- let awsRegion = "us-east-1";
735
-
736
- if (storageProvider === "minio") {
737
- minioEndpoint = await prompt(
738
- text({
739
- defaultValue: minioEndpoint,
740
- message: "MinIO host?",
741
- validate(value) {
742
- return value.trim().length === 0 ? "MinIO host is required" : undefined;
743
- },
744
- }),
745
- );
746
- minioPort = await promptNumber("MinIO port?", minioPort);
747
- minioUseSSL = await prompt(
748
- confirm({
749
- initialValue: false,
750
- message: "Use SSL for MinIO?",
751
- }),
752
- );
753
- minioAccessKey = await prompt(
754
- text({
755
- defaultValue: minioAccessKey,
756
- message: "MinIO access key?",
757
- validate(value) {
758
- return value.trim().length === 0 ? "MinIO access key is required" : undefined;
759
- },
760
- }),
761
- );
762
- minioSecretKey = await promptSecret("MinIO secret key?", 3, minioSecretKey);
763
- minioBucket = await prompt(
764
- text({
765
- defaultValue: minioBucket,
766
- message: "MinIO bucket name?",
767
- validate(value) {
768
- return value.trim().length === 0 ? "MinIO bucket name is required" : undefined;
769
- },
770
- }),
771
- );
772
- minioPublicURL = await prompt(
773
- text({
774
- defaultValue: minioPublicURL,
775
- message: "MinIO public URL?",
776
- validate(value) {
777
- return isHttpUrl(value) ? undefined : "Enter a valid URL";
778
- },
779
- }),
780
- );
781
- } else {
782
- awsEndpointURL = await prompt(
783
- text({
784
- defaultValue: awsEndpointURL,
785
- message: "AWS endpoint URL? (leave empty for AWS)",
786
- }),
787
- );
788
- awsAccessKeyId = await prompt(
789
- text({
790
- message: "AWS access key id?",
791
- validate(value) {
792
- return value.trim().length === 0 ? "AWS access key id is required" : undefined;
793
- },
794
- }),
795
- );
796
- awsSecretAccessKey = await promptSecret("AWS secret access key?", 3);
797
- awsBucket = await prompt(
798
- text({
799
- message: "AWS bucket name?",
800
- validate(value) {
801
- return value.trim().length === 0 ? "AWS bucket name is required" : undefined;
802
- },
803
- }),
804
- );
805
- awsRegion = await prompt(
806
- text({
807
- defaultValue: awsRegion,
808
- message: "AWS region?",
809
- validate(value) {
810
- return value.trim().length === 0 ? "AWS region is required" : undefined;
811
- },
812
- }),
813
- );
814
- }
815
-
816
- const mailProvider = await prompt(
817
- select({
818
- initialValue: "mailchimp",
819
- message: "Transactional email mode?",
820
- options: [
821
- { label: "Mailchimp Transactional", value: "mailchimp" },
822
- { label: "Brevo", value: "brevo" },
823
- { label: "SMTP", value: "smtp" },
824
- ],
825
- }),
826
- );
827
-
828
- const mailFromEmail = await promptEmail(
829
- "Transactional sender email?",
830
- `no-reply@${slug || "asaje-app"}.local`,
831
- );
832
- const mailFromName = await prompt(
833
- text({
834
- defaultValue: appName,
835
- message: "Transactional sender name?",
836
- validate(value) {
837
- return value.trim().length === 0 ? "Sender name is required" : undefined;
838
- },
839
- }),
840
- );
841
- const mailchimpApiKey = mailProvider === "mailchimp" ? await promptSecret("Mailchimp Transactional API key?", 3) : "";
842
- const brevoApiKey = mailProvider === "brevo" ? await promptSecret("Brevo API key?", 3) : "";
843
- const smtpHost =
844
- mailProvider === "smtp"
845
- ? await prompt(
846
- text({
847
- defaultValue: "smtp.gmail.com",
848
- message: "SMTP host?",
849
- validate(value) {
850
- return value.trim().length === 0 ? "SMTP host is required" : undefined;
851
- },
852
- }),
853
- )
854
- : "";
855
- const smtpPort = mailProvider === "smtp" ? await promptNumber("SMTP port?", 587) : 587;
856
- const smtpUsername = mailProvider === "smtp" ? await prompt(text({ defaultValue: "", message: "SMTP username?" })) : "";
857
- const smtpPassword = mailProvider === "smtp" ? await promptSecret("SMTP password?", 0, "") : "";
858
- const smtpUseSSL =
859
- mailProvider === "smtp"
860
- ? await prompt(
861
- confirm({
862
- initialValue: false,
863
- message: "Use SSL for SMTP?",
864
- }),
865
- )
866
- : false;
867
-
868
- const includeLanding = await prompt(
869
- confirm({
870
- initialValue: true,
871
- message: "Enable the optional landing surface?",
872
- }),
873
- );
874
- const includePwa = await prompt(
875
- confirm({
876
- initialValue: true,
877
- message: "Enable the optional PWA surface?",
878
- }),
879
- );
210
+ const destinationDir = path.resolve(process.cwd(), answers.directory);
880
211
 
881
- const enableGithubWorkflow = await prompt(
882
- confirm({
883
- initialValue: true,
884
- message: "Generate a GitHub Actions Railway deploy workflow?",
885
- }),
886
- );
887
- const productionBranch = enableGithubWorkflow
888
- ? await prompt(
889
- text({
890
- defaultValue: "main",
891
- message: "Production branch?",
892
- validate(value) {
893
- return value.trim().length === 0 ? "Production branch is required" : undefined;
894
- },
895
- }),
896
- )
897
- : "main";
898
- const stagingBranch = enableGithubWorkflow
899
- ? await prompt(
900
- text({
901
- defaultValue: "develop",
902
- message: "Staging branch?",
903
- validate(value) {
904
- return value.trim().length === 0 ? "Staging branch is required" : undefined;
905
- },
906
- }),
907
- )
908
- : "develop";
212
+ await ensureDestinationIsAvailable(destinationDir);
909
213
 
910
- const corsAllowedOrigins = await prompt(
911
- text({
912
- defaultValue: "",
913
- message: "Extra CORS origins? (comma-separated, optional)",
914
- placeholder: "https://app.example.com,https://admin.example.com",
915
- }),
916
- );
214
+ console.log(pc.dim("\nScaffolding project from GitHub template..."));
215
+ await cloneTemplate(answers.template, answers.branch, destinationDir);
216
+ await cleanupTemplateFiles(destinationDir);
217
+ await ensureScaffoldedSurfaces(destinationDir, answers);
218
+ await writeProjectConfig(destinationDir, answers);
219
+ await writeEnvFiles(destinationDir, answers);
220
+ await writeProjectMakefile(destinationDir, answers);
221
+ await writeProjectReadme(destinationDir, answers);
222
+ await writeGithubWorkflow(destinationDir, answers);
917
223
 
918
- const installDependencies = await prompt(
919
- confirm({
920
- initialValue: args.installDependencies ?? true,
921
- message: "Install dependencies now?",
922
- }),
923
- );
224
+ if (answers.installDependencies) {
225
+ console.log(pc.dim("\nInstalling frontend and Go dependencies..."));
226
+ await installProjectDependencies(destinationDir);
227
+ }
924
228
 
925
- const startInfra = await prompt(
926
- confirm({
927
- initialValue: args.startInfra ?? true,
928
- message: "Start local Docker services now?",
929
- }),
930
- );
229
+ if (answers.startInfra) {
230
+ console.log(pc.dim("\nStarting local infrastructure with Docker Compose..."));
231
+ await startInfrastructure(destinationDir);
232
+ }
931
233
 
932
- return buildCreateAnswers({
933
- adminEmail,
934
- adminName,
935
- adminPassword,
936
- adminPort,
937
- apiPort,
938
- appName,
939
- awsAccessKeyId,
940
- awsBucket,
941
- awsEndpointURL,
942
- awsRegion,
943
- awsSecretAccessKey,
944
- branch: args.branch,
945
- bucketBasePath,
946
- corsAllowedOrigins,
947
- defaultLocale,
948
- directory,
949
- enableGithubWorkflow,
950
- includeLanding,
951
- includePwa,
952
- installDependencies,
953
- brevoApiKey,
954
- mailFromEmail,
955
- mailFromName,
956
- mailProvider,
957
- mailchimpApiKey,
958
- minioAccessKey,
959
- minioBucket,
960
- minioEndpoint,
961
- minioPort,
962
- minioPublicURL,
963
- minioSecretKey,
964
- minioUseSSL,
965
- realtimePort,
966
- seedAdmin,
967
- seedUser,
968
- seedUserEmail,
969
- seedUserName,
970
- seedUserPassword,
971
- startInfra,
972
- storageProvider,
973
- swaggerUsername,
974
- smtpHost,
975
- smtpPassword,
976
- smtpPort,
977
- smtpUseSSL,
978
- smtpUsername,
979
- stagingBranch,
980
- template: args.template,
981
- productionBranch,
982
- });
234
+ printCreateSummary(destinationDir, answers);
983
235
  }
984
236
 
985
237
  async function collectUpdateAnswers(args) {
@@ -1020,115 +272,6 @@ async function collectUpdateAnswers(args) {
1020
272
  };
1021
273
  }
1022
274
 
1023
- function buildCreateAnswers(input) {
1024
- const directory = input.directory.trim();
1025
- const slug = slugify(path.basename(directory));
1026
- const appName = input.appName.trim();
1027
- const databaseName = slug.replace(/-/g, "_") || "asaje_app";
1028
- const swaggerPassword = randomSecret(20);
1029
- const jwtSecret = randomSecret(32);
1030
- const corsAllowedOrigins = [
1031
- `http://localhost:${input.adminPort}`,
1032
- ...splitCsv(input.corsAllowedOrigins || ""),
1033
- ];
1034
-
1035
- return {
1036
- admin: input.seedAdmin
1037
- ? {
1038
- email: input.adminEmail.trim(),
1039
- name: input.adminName.trim(),
1040
- password: input.adminPassword,
1041
- }
1042
- : null,
1043
- adminPort: input.adminPort,
1044
- apiPort: input.apiPort,
1045
- appName,
1046
- aws: {
1047
- accessKeyId: (input.awsAccessKeyId || "").trim(),
1048
- bucket: (input.awsBucket || "").trim(),
1049
- endpointUrl: (input.awsEndpointURL || "").trim(),
1050
- region: (input.awsRegion || "us-east-1").trim(),
1051
- secretAccessKey: input.awsSecretAccessKey || "",
1052
- },
1053
- branch: input.branch,
1054
- brevoApiKey: input.brevoApiKey || "",
1055
- bucketBasePath: (input.bucketBasePath || slug).trim(),
1056
- corsAllowedOrigins,
1057
- databaseName,
1058
- defaultLocale: input.defaultLocale,
1059
- directory,
1060
- github: {
1061
- enabled: Boolean(input.enableGithubWorkflow),
1062
- branchEnvironments: Boolean(input.enableGithubWorkflow)
1063
- ? [
1064
- { branch: (input.productionBranch || "main").trim(), environment: "production" },
1065
- { branch: (input.stagingBranch || "develop").trim(), environment: "staging" },
1066
- ].filter((entry, index, items) => entry.branch && items.findIndex((candidate) => candidate.branch === entry.branch) === index)
1067
- : [],
1068
- },
1069
- includeLanding: Boolean(input.includeLanding),
1070
- includePwa: Boolean(input.includePwa),
1071
- installDependencies: input.installDependencies,
1072
- jwtSecret,
1073
- mailFromEmail: input.mailFromEmail.trim(),
1074
- mailFromName: input.mailFromName.trim(),
1075
- mailProvider: input.mailProvider,
1076
- mailchimpApiKey: input.mailchimpApiKey,
1077
- minio: {
1078
- accessKey: input.minioAccessKey || "minioadmin",
1079
- bucket: input.minioBucket || "boilerplate-files",
1080
- endpoint: input.minioEndpoint || "localhost",
1081
- port: input.minioPort || 9000,
1082
- publicUrl:
1083
- input.minioPublicURL || `${input.minioUseSSL ? "https" : "http"}://${input.minioEndpoint}:${input.minioPort}`,
1084
- secretKey: input.minioSecretKey || "minioadmin",
1085
- useSSL: Boolean(input.minioUseSSL),
1086
- },
1087
- realtimePort: input.realtimePort,
1088
- seedAdmin: input.seedAdmin,
1089
- seedUser: input.seedUser,
1090
- slug,
1091
- smtp: {
1092
- host: (input.smtpHost || "").trim(),
1093
- password: input.smtpPassword || "",
1094
- port: input.smtpPort || 587,
1095
- useSSL: Boolean(input.smtpUseSSL),
1096
- username: (input.smtpUsername || "").trim(),
1097
- },
1098
- standardUser: input.seedUser
1099
- ? {
1100
- email: input.seedUserEmail.trim(),
1101
- name: input.seedUserName.trim(),
1102
- password: input.seedUserPassword,
1103
- }
1104
- : null,
1105
- startInfra: input.startInfra,
1106
- storageProvider: input.storageProvider,
1107
- swaggerPassword,
1108
- swaggerUsername: input.swaggerUsername.trim(),
1109
- template: input.template,
1110
- };
1111
- }
1112
-
1113
- function buildCreateRailwayServices(answers) {
1114
- return DEFAULT_RAILWAY_APP_SERVICE_SPECS.filter((spec) => {
1115
- if (spec.key === "landing") {
1116
- return answers.includeLanding;
1117
- }
1118
- if (spec.key === "pwa") {
1119
- return answers.includePwa;
1120
- }
1121
- return true;
1122
- }).map((spec) => ({
1123
- aliases: [...spec.aliases],
1124
- baseName: spec.baseName,
1125
- directory: spec.directory,
1126
- dockerfile: spec.key === "worker" ? "api/Dockerfile" : `${spec.directory}/Dockerfile`,
1127
- key: spec.key,
1128
- seedImage: spec.key === "admin" || spec.key === "landing" || spec.key === "pwa" ? "nginx:1.29-alpine" : "alpine:3.22",
1129
- }));
1130
- }
1131
-
1132
275
  async function writeProjectConfig(destinationDir, answers) {
1133
276
  const config = {
1134
277
  projectName: answers.appName,
@@ -1180,6 +323,7 @@ function buildCreateRailwayEnvironments(answers) {
1180
323
  }
1181
324
 
1182
325
  async function writeEnvFiles(destinationDir, answers) {
326
+ await fs.ensureDir(path.join(destinationDir, "admin"));
1183
327
  await fs.writeFile(
1184
328
  path.join(destinationDir, "admin/.env"),
1185
329
  toEnvContent({
@@ -1187,7 +331,7 @@ async function writeEnvFiles(destinationDir, answers) {
1187
331
  VITE_API_BASE_URL: `http://localhost:${answers.apiPort}/api/v1`,
1188
332
  VITE_API_TIMEOUT_MS: "15000",
1189
333
  VITE_REALTIME_BASE_URL: `http://localhost:${answers.realtimePort}`,
1190
- VITE_REALTIME_DEFAULT_TRANSPORT: "sse",
334
+ VITE_REALTIME_DEFAULT_TRANSPORT: "ws",
1191
335
  VITE_REALTIME_RECONNECT_DELAY_MS: "3000",
1192
336
  }),
1193
337
  );
@@ -1248,8 +392,10 @@ async function writeEnvFiles(destinationDir, answers) {
1248
392
  RABBITMQ_WORKER_CONSUMER_TAG: `${answers.slug || "asaje-app"}-api-worker`,
1249
393
  };
1250
394
 
395
+ await fs.ensureDir(path.join(destinationDir, "api"));
1251
396
  await fs.writeFile(path.join(destinationDir, "api/.env"), toEnvContent(apiEnv));
1252
397
 
398
+ await fs.ensureDir(path.join(destinationDir, "realtime-gateway"));
1253
399
  await fs.writeFile(
1254
400
  path.join(destinationDir, "realtime-gateway/.env"),
1255
401
  toEnvContent({
@@ -1266,6 +412,7 @@ async function writeEnvFiles(destinationDir, answers) {
1266
412
  );
1267
413
 
1268
414
  if (answers.includeLanding) {
415
+ await fs.ensureDir(path.join(destinationDir, "landing"));
1269
416
  await fs.writeFile(
1270
417
  path.join(destinationDir, "landing/.env"),
1271
418
  toEnvContent({
@@ -1276,6 +423,7 @@ async function writeEnvFiles(destinationDir, answers) {
1276
423
  }
1277
424
 
1278
425
  if (answers.includePwa) {
426
+ await fs.ensureDir(path.join(destinationDir, "pwa"));
1279
427
  await fs.writeFile(
1280
428
  path.join(destinationDir, "pwa/.env"),
1281
429
  toEnvContent({
@@ -1301,111 +449,8 @@ async function writeProjectReadme(destinationDir, answers) {
1301
449
  await fs.writeFile(path.join(destinationDir, "README.md"), buildProjectReadmeContent(answers));
1302
450
  }
1303
451
 
1304
- function buildProjectReadmeContent(answers) {
1305
- const surfaces = [
1306
- "- `admin/`: Vue 3 admin SPA for back-office and internal tooling",
1307
- "- `api/`: Go HTTP API with clean architecture and PostgreSQL",
1308
- "- `realtime-gateway/`: Go realtime transport service for SSE/WebSocket",
1309
- answers.includeLanding ? "- `landing/`: optional public marketing surface" : null,
1310
- answers.includePwa ? "- `pwa/`: optional installable end-user PWA surface" : null,
1311
- ].filter(Boolean).join("\n");
1312
-
1313
- const ciMappings = (answers.github?.branchEnvironments || [])
1314
- .map((entry) => `- \`${entry.branch}\` -> \`${entry.environment}\``)
1315
- .join("\n");
1316
-
1317
- return `# ${answers.appName}
1318
-
1319
- Generated with \`create-asaje-go-vue\` / \`asaje\`.
1320
-
1321
- ## Stack
1322
-
1323
- - Frontend admin: Vue 3, Vite, Pinia, Vue Router, vue-i18n, shadcn-vue
1324
- - API: Go, Gin, GORM, PostgreSQL, JWT
1325
- - Async and realtime: RabbitMQ + realtime gateway (SSE/WebSocket)
1326
- - File storage: ${answers.storageProvider === "aws" ? "AWS S3" : "MinIO / S3-compatible"}
1327
- - Optional mail providers: Mailchimp Transactional, Brevo, SMTP
1328
- - Deployment tooling: Railway + GitHub Actions via \`asaje\`
1329
-
1330
- ## Project Surfaces
1331
-
1332
- ${surfaces}
1333
-
1334
- ## How Things Are Linked
1335
-
1336
- - The admin and PWA call the API through domain API modules, not raw fetches.
1337
- - The API owns business logic and persistence.
1338
- - The API publishes async tasks and realtime events to RabbitMQ.
1339
- - The realtime gateway consumes realtime events and pushes them to browsers.
1340
- - File uploads go through the API and object storage, with stable media URLs exposed by the API.
1341
-
1342
- ## Local Development
1343
-
1344
- Install and start the project:
1345
-
1346
- \`\`\`bash
1347
- docker compose up -d
1348
- npx -p create-asaje-go-vue asaje start .
1349
- \`\`\`
1350
-
1351
- Useful local URLs:
1352
-
1353
- - Admin: http://localhost:${answers.adminPort}
1354
- ${answers.includeLanding ? `- Landing: http://localhost:8088
1355
- ` : ""}${answers.includePwa ? `- PWA: http://localhost:4174
1356
- ` : ""}- API: http://localhost:${answers.apiPort}/api/v1
1357
- - Swagger: http://localhost:${answers.apiPort}/swagger/index.html
1358
- - Realtime gateway: http://localhost:${answers.realtimePort}
1359
-
1360
- ## Asaje Commands
1361
-
1362
- - \`asaje start .\`: run local services
1363
- - \`asaje doctor .\`: check tooling and project readiness
1364
- - \`asaje update .\`: update managed boilerplate files from the template
1365
- - \`asaje sync-project-config .\`: rescan the project and rewrite config manifests
1366
- - \`asaje setup-railway .\`: provision Railway resources and first deploy
1367
- - \`asaje update-railway .\`: reconcile Railway resources, services, and variables
1368
- - \`asaje sync-railway-env .\`: sync only Railway environment variables
1369
- - \`asaje deploy-railway .\`: deploy the current source tree to Railway
1370
- - \`asaje sync-github-workflow .\`: regenerate the GitHub Actions Railway workflow from config
1371
-
1372
- ## Railway And GitHub Actions
1373
-
1374
- - Railway variable mode defaults to \`preserve-remote\`, so existing Railway values are kept unless you explicitly override them in \`asaje.config.json\`.
1375
- ${answers.github?.enabled ? `- GitHub Actions deploy workflow is generated in \`.github/workflows/deploy-railway.yml\`.
1376
- - Branch to environment mapping:
1377
- ${ciMappings}
1378
- - Add \`RAILWAY_TOKEN\` to your GitHub repository secrets before enabling automatic deploys.
1379
- ` : "- No GitHub Actions Railway workflow was generated during bootstrap. You can enable it later in \`asaje.config.json\` and run \`asaje sync-github-workflow .\`.\n"}
1380
- ## Important Files
1381
-
1382
- - \`asaje.config.json\`: project config, Railway config, CI/CD metadata
1383
- - \`asaje.railway.json\`: local manifest of discovered Railway services/resources
1384
- - \`api/notifications.yaml\`: generic notification event/channel templates
1385
- - \`api/crons.yaml\`: worker cron configuration
1386
-
1387
- ## Notes
1388
-
1389
- - This project is designed to stay modular: keep generic infrastructure in the boilerplate, and move product-specific business logic into your app domain.
1390
- - When you add new app surfaces or Dockerfiles, rerun \`asaje sync-project-config .\`.
1391
- `;
1392
- }
1393
-
1394
- function buildReadmeAnswersFromProjectConfig(projectDir, projectConfig) {
1395
- const ports = projectConfig?.ports || {};
1396
- const appServiceSpecs = resolveRailwayAppServiceSpecs(projectConfig);
1397
- const githubConfig = getGithubActionsDeployConfig(projectConfig);
1398
-
1399
- return {
1400
- adminPort: Number(ports.admin || 5173),
1401
- apiPort: Number(ports.api || 8080),
1402
- appName: String(projectConfig?.projectName || path.basename(projectDir)),
1403
- github: githubConfig,
1404
- includeLanding: appServiceSpecs.some((spec) => spec.key === "landing"),
1405
- includePwa: appServiceSpecs.some((spec) => spec.key === "pwa"),
1406
- realtimePort: Number(ports.realtime || 8090),
1407
- storageProvider: String(projectConfig?.services?.storageProvider || "minio"),
1408
- };
452
+ async function writeProjectMakefile(destinationDir, answers) {
453
+ await fs.writeFile(path.join(destinationDir, "Makefile"), buildProjectMakefileContent(answers));
1409
454
  }
1410
455
 
1411
456
  async function writeGithubWorkflowFromProjectConfig(projectDir, projectConfig) {
@@ -1424,147 +469,6 @@ async function writeGithubWorkflowFromProjectConfig(projectDir, projectConfig) {
1424
469
  await fs.writeFile(workflowPath, buildGithubWorkflowContent(answers));
1425
470
  }
1426
471
 
1427
- function getGithubActionsDeployConfig(projectConfig) {
1428
- const deployRailway = projectConfig?.ci?.githubActions?.deployRailway;
1429
- const rawMappings = Array.isArray(deployRailway?.branchEnvironments) ? deployRailway.branchEnvironments : [];
1430
- const branchEnvironments = rawMappings
1431
- .map((entry) => ({
1432
- branch: String(entry?.branch || "").trim(),
1433
- environment: String(entry?.environment || "").trim(),
1434
- }))
1435
- .filter((entry, index, items) => entry.branch && entry.environment && items.findIndex((candidate) => candidate.branch === entry.branch) === index);
1436
-
1437
- return {
1438
- branchEnvironments,
1439
- enabled: Boolean(deployRailway?.enabled) && branchEnvironments.length > 0,
1440
- };
1441
- }
1442
-
1443
- function buildGithubWorkflowContent(answers) {
1444
- const branchEnvironments = answers.github.branchEnvironments;
1445
- const branchList = branchEnvironments.map((entry) => ` - ${entry.branch}`).join("\n");
1446
-
1447
- const branchCase = branchEnvironments
1448
- .map((entry) => ` ${entry.branch}) echo "environment=${entry.environment}" >> "$GITHUB_OUTPUT" ;;
1449
- `)
1450
- .join("");
1451
-
1452
- const managedServices = buildCreateRailwayServices(answers).map((spec) => spec.key);
1453
- const hasLanding = managedServices.includes("landing");
1454
- const hasPwa = managedServices.includes("pwa");
1455
-
1456
- return `name: Deploy Railway
1457
-
1458
- on:
1459
- push:
1460
- branches:
1461
- ${branchList}
1462
- workflow_dispatch:
1463
-
1464
- jobs:
1465
- detect-changes:
1466
- runs-on: ubuntu-latest
1467
- outputs:
1468
- admin: \${{ steps.filter.outputs.admin }}
1469
- api: \${{ steps.filter.outputs.api }}
1470
- config: \${{ steps.filter.outputs.config }}
1471
- landing: \${{ steps.filter.outputs.landing }}
1472
- pwa: \${{ steps.filter.outputs.pwa }}
1473
- realtime: \${{ steps.filter.outputs.realtime }}
1474
- steps:
1475
- - uses: actions/checkout@v4
1476
- with:
1477
- fetch-depth: 0
1478
-
1479
- - uses: dorny/paths-filter@v3
1480
- id: filter
1481
- with:
1482
- filters: |
1483
- api:
1484
- - 'api/**'
1485
- realtime:
1486
- - 'realtime-gateway/**'
1487
- admin:
1488
- - 'admin/**'
1489
- landing:
1490
- - 'landing/**'
1491
- pwa:
1492
- - 'pwa/**'
1493
- config:
1494
- - 'asaje.config.json'
1495
- - 'asaje.railway.json'
1496
- - 'docker-compose.yml'
1497
-
1498
- deploy:
1499
- needs: detect-changes
1500
- runs-on: ubuntu-latest
1501
- steps:
1502
- - uses: actions/checkout@v4
1503
-
1504
- - uses: pnpm/action-setup@v4
1505
- with:
1506
- version: 9
1507
-
1508
- - uses: actions/setup-node@v4
1509
- with:
1510
- node-version: 22
1511
-
1512
- - name: Install Railway CLI
1513
- run: npm install -g @railway/cli
1514
-
1515
- - name: Resolve target environment
1516
- id: target
1517
- shell: bash
1518
- run: |
1519
- branch="\${GITHUB_REF_NAME}"
1520
- case "$branch" in
1521
- ${branchCase} *) echo "Unsupported branch: $branch" >&2; exit 1 ;;
1522
- esac
1523
-
1524
- - name: Sync Railway environment
1525
- if: needs.detect-changes.outputs.config == 'true'
1526
- env:
1527
- RAILWAY_TOKEN: \${{ secrets.RAILWAY_TOKEN }}
1528
- run: npx -p create-asaje-go-vue@latest asaje sync-railway-env . --yes --environment \${{ steps.target.outputs.environment }}
1529
-
1530
- - name: Deploy api
1531
- if: needs.detect-changes.outputs.api == 'true' || needs.detect-changes.outputs.config == 'true'
1532
- env:
1533
- RAILWAY_TOKEN: \${{ secrets.RAILWAY_TOKEN }}
1534
- run: npx -p create-asaje-go-vue@latest asaje deploy-railway . --service api --environment \${{ steps.target.outputs.environment }}
1535
-
1536
- - name: Deploy worker
1537
- if: needs.detect-changes.outputs.api == 'true' || needs.detect-changes.outputs.config == 'true'
1538
- env:
1539
- RAILWAY_TOKEN: \${{ secrets.RAILWAY_TOKEN }}
1540
- run: npx -p create-asaje-go-vue@latest asaje deploy-railway . --service worker --environment \${{ steps.target.outputs.environment }}
1541
-
1542
- - name: Deploy realtime
1543
- if: needs.detect-changes.outputs.realtime == 'true' || needs.detect-changes.outputs.config == 'true'
1544
- env:
1545
- RAILWAY_TOKEN: \${{ secrets.RAILWAY_TOKEN }}
1546
- run: npx -p create-asaje-go-vue@latest asaje deploy-railway . --service realtime --environment \${{ steps.target.outputs.environment }}
1547
-
1548
- - name: Deploy admin
1549
- if: needs.detect-changes.outputs.admin == 'true' || needs.detect-changes.outputs.config == 'true'
1550
- env:
1551
- RAILWAY_TOKEN: \${{ secrets.RAILWAY_TOKEN }}
1552
- run: npx -p create-asaje-go-vue@latest asaje deploy-railway . --service admin --environment \${{ steps.target.outputs.environment }}
1553
- ${hasLanding ? `
1554
- - name: Deploy landing
1555
- if: needs.detect-changes.outputs.landing == 'true' || needs.detect-changes.outputs.config == 'true'
1556
- env:
1557
- RAILWAY_TOKEN: \${{ secrets.RAILWAY_TOKEN }}
1558
- run: npx -p create-asaje-go-vue@latest asaje deploy-railway . --service landing --environment \${{ steps.target.outputs.environment }}
1559
- ` : ""}${hasPwa ? `
1560
- - name: Deploy pwa
1561
- if: needs.detect-changes.outputs.pwa == 'true' || needs.detect-changes.outputs.config == 'true'
1562
- env:
1563
- RAILWAY_TOKEN: \${{ secrets.RAILWAY_TOKEN }}
1564
- run: npx -p create-asaje-go-vue@latest asaje deploy-railway . --service pwa --environment \${{ steps.target.outputs.environment }}
1565
- ` : ""}`;
1566
- }
1567
-
1568
472
  function printCreateSummary(destinationDir, answers) {
1569
473
  console.log(pc.green("\nSetup complete."));
1570
474
  console.log(`- Project: ${pc.bold(answers.appName)}`);
@@ -1603,7 +507,11 @@ async function runStart(argv) {
1603
507
  const projectDir = path.resolve(process.cwd(), answers.directory);
1604
508
 
1605
509
  await ensureProjectStructure(projectDir);
1606
- await ensureEnvFiles(projectDir);
510
+ await ensureEnvFiles(projectDir, {
511
+ onCreated(spec) {
512
+ console.log(pc.yellow(`- Created ${spec.envPath} from ${spec.examplePath}`));
513
+ },
514
+ });
1607
515
 
1608
516
  if (answers.installDependencies) {
1609
517
  console.log(pc.dim("\nInstalling frontend and Go dependencies..."));
@@ -1830,7 +738,7 @@ async function runSyncGithubWorkflow(argv) {
1830
738
  }
1831
739
 
1832
740
  async function runSetupRailway(argv) {
1833
- const args = parseSetupRailwayArgs(argv);
741
+ const args = parseSetupRailwayArgs(argv, { defaultBucket: DEFAULT_RAILWAY_BUCKET });
1834
742
  const answers = await collectSetupRailwayAnswers(args);
1835
743
  const projectDir = path.resolve(process.cwd(), answers.directory);
1836
744
  const projectConfig = await loadProjectConfig(projectDir);
@@ -1986,7 +894,7 @@ async function runUpdateRailway(argv) {
1986
894
  }
1987
895
 
1988
896
  async function runSyncRailwayEnv(argv) {
1989
- const args = parseSetupRailwayArgs(argv);
897
+ const args = parseSetupRailwayArgs(argv, { defaultBucket: DEFAULT_RAILWAY_BUCKET });
1990
898
  const answers = await collectSetupRailwayAnswers(args);
1991
899
  const projectDir = path.resolve(process.cwd(), answers.directory);
1992
900
  const projectConfig = await loadProjectConfig(projectDir);
@@ -2324,95 +1232,6 @@ async function runDestroyRailway(argv) {
2324
1232
  }
2325
1233
  }
2326
1234
 
2327
- function parseDirectoryArgs(argv) {
2328
- return { directory: argv[0] || "." };
2329
- }
2330
-
2331
- function parseSetupRailwayArgs(argv) {
2332
- const options = {
2333
- bucket: DEFAULT_RAILWAY_BUCKET,
2334
- bucketProvided: false,
2335
- directory: ".",
2336
- diff: false,
2337
- dryRun: false,
2338
- environment: undefined,
2339
- services: [],
2340
- yes: false,
2341
- };
2342
- const positionals = [];
2343
-
2344
- for (let index = 0; index < argv.length; index += 1) {
2345
- const arg = argv[index];
2346
-
2347
- if (arg === "--yes" || arg === "-y") {
2348
- options.yes = true;
2349
- continue;
2350
- }
2351
-
2352
- if (arg === "--dry-run") {
2353
- options.dryRun = true;
2354
- continue;
2355
- }
2356
-
2357
- if (arg === "--diff") {
2358
- options.diff = true;
2359
- continue;
2360
- }
2361
-
2362
- if (arg === "--bucket") {
2363
- options.bucket = argv[index + 1] || options.bucket;
2364
- options.bucketProvided = true;
2365
- index += 1;
2366
- continue;
2367
- }
2368
-
2369
- if (arg.startsWith("--bucket=")) {
2370
- options.bucket = arg.split("=")[1] || options.bucket;
2371
- options.bucketProvided = true;
2372
- continue;
2373
- }
2374
-
2375
- if (arg === "--environment" || arg === "-e") {
2376
- options.environment = argv[index + 1] || options.environment;
2377
- index += 1;
2378
- continue;
2379
- }
2380
-
2381
- if (arg.startsWith("--environment=")) {
2382
- options.environment = arg.split("=")[1] || options.environment;
2383
- continue;
2384
- }
2385
-
2386
- if (arg === "--service") {
2387
- options.services.push(argv[index + 1] || "");
2388
- index += 1;
2389
- continue;
2390
- }
2391
-
2392
- if (arg.startsWith("--service=")) {
2393
- options.services.push(arg.split("=")[1] || "");
2394
- continue;
2395
- }
2396
-
2397
- if (arg === "--services") {
2398
- options.services.push(...splitCsv(argv[index + 1] || ""));
2399
- index += 1;
2400
- continue;
2401
- }
2402
-
2403
- if (arg.startsWith("--services=")) {
2404
- options.services.push(...splitCsv(arg.split("=")[1] || ""));
2405
- continue;
2406
- }
2407
-
2408
- positionals.push(arg);
2409
- }
2410
-
2411
- options.directory = positionals[0] || options.directory;
2412
- options.services = [...new Set(options.services.map((service) => service.trim()).filter(Boolean))];
2413
- return options;
2414
- }
2415
-
2416
1235
  function parsePrintRailwayConfigArgs(argv) {
2417
1236
  const options = {
2418
1237
  directory: ".",
@@ -2598,106 +1417,8 @@ function parseSyncGithubWorkflowArgs(argv) {
2598
1417
  for (let index = 0; index < argv.length; index += 1) {
2599
1418
  const arg = argv[index];
2600
1419
 
2601
- if (arg === "--dry-run") {
2602
- options.dryRun = true;
2603
- continue;
2604
- }
2605
-
2606
- positionals.push(arg);
2607
- }
2608
-
2609
- options.directory = positionals[0] || options.directory;
2610
- return options;
2611
- }
2612
-
2613
- function parseSyncReadmeArgs(argv) {
2614
- const options = {
2615
- directory: ".",
2616
- dryRun: false,
2617
- };
2618
- const positionals = [];
2619
-
2620
- for (let index = 0; index < argv.length; index += 1) {
2621
- const arg = argv[index];
2622
-
2623
- if (arg === "--dry-run") {
2624
- options.dryRun = true;
2625
- continue;
2626
- }
2627
-
2628
- positionals.push(arg);
2629
- }
2630
-
2631
- options.directory = positionals[0] || options.directory;
2632
- return options;
2633
- }
2634
-
2635
- function parseDiffRailwayConfigArgs(argv) {
2636
- const options = {
2637
- compareEnvironment: undefined,
2638
- compareFile: undefined,
2639
- directory: ".",
2640
- environment: undefined,
2641
- file: undefined,
2642
- json: false,
2643
- showSecrets: false,
2644
- };
2645
- const positionals = [];
2646
-
2647
- for (let index = 0; index < argv.length; index += 1) {
2648
- const arg = argv[index];
2649
-
2650
- if (arg === "--json") {
2651
- options.json = true;
2652
- continue;
2653
- }
2654
-
2655
- if (arg === "--show-secrets") {
2656
- options.showSecrets = true;
2657
- continue;
2658
- }
2659
-
2660
- if (arg === "--environment" || arg === "-e") {
2661
- options.environment = argv[index + 1] || options.environment;
2662
- index += 1;
2663
- continue;
2664
- }
2665
-
2666
- if (arg.startsWith("--environment=")) {
2667
- options.environment = arg.split("=")[1] || options.environment;
2668
- continue;
2669
- }
2670
-
2671
- if (arg === "--compare-environment") {
2672
- options.compareEnvironment = argv[index + 1] || options.compareEnvironment;
2673
- index += 1;
2674
- continue;
2675
- }
2676
-
2677
- if (arg.startsWith("--compare-environment=")) {
2678
- options.compareEnvironment = arg.split("=")[1] || options.compareEnvironment;
2679
- continue;
2680
- }
2681
-
2682
- if (arg === "--file" || arg === "-f") {
2683
- options.file = argv[index + 1] || options.file;
2684
- index += 1;
2685
- continue;
2686
- }
2687
-
2688
- if (arg.startsWith("--file=")) {
2689
- options.file = arg.split("=")[1] || options.file;
2690
- continue;
2691
- }
2692
-
2693
- if (arg === "--compare-file") {
2694
- options.compareFile = argv[index + 1] || options.compareFile;
2695
- index += 1;
2696
- continue;
2697
- }
2698
-
2699
- if (arg.startsWith("--compare-file=")) {
2700
- options.compareFile = arg.split("=")[1] || options.compareFile;
1420
+ if (arg === "--dry-run") {
1421
+ options.dryRun = true;
2701
1422
  continue;
2702
1423
  }
2703
1424
 
@@ -2705,97 +1426,53 @@ function parseDiffRailwayConfigArgs(argv) {
2705
1426
  }
2706
1427
 
2707
1428
  options.directory = positionals[0] || options.directory;
2708
- if (!options.compareEnvironment && !options.compareFile) {
2709
- throw new Error("diff-railway-config requires --compare-environment <name> or --compare-file <snapshot.json>.");
2710
- }
2711
1429
  return options;
2712
1430
  }
2713
1431
 
2714
- function parseDeployRailwayArgs(argv) {
1432
+ function parseSyncReadmeArgs(argv) {
2715
1433
  const options = {
2716
1434
  directory: ".",
2717
1435
  dryRun: false,
2718
- environment: undefined,
2719
- services: [],
2720
- yes: false,
2721
1436
  };
2722
1437
  const positionals = [];
2723
1438
 
2724
1439
  for (let index = 0; index < argv.length; index += 1) {
2725
1440
  const arg = argv[index];
2726
1441
 
2727
- if (arg === "--yes" || arg === "-y") {
2728
- options.yes = true;
2729
- continue;
2730
- }
2731
-
2732
1442
  if (arg === "--dry-run") {
2733
1443
  options.dryRun = true;
2734
1444
  continue;
2735
1445
  }
2736
1446
 
2737
- if (arg === "--environment" || arg === "-e") {
2738
- options.environment = argv[index + 1] || options.environment;
2739
- index += 1;
2740
- continue;
2741
- }
2742
-
2743
- if (arg.startsWith("--environment=")) {
2744
- options.environment = arg.split("=")[1] || options.environment;
2745
- continue;
2746
- }
2747
-
2748
- if (arg === "--service") {
2749
- options.services.push(argv[index + 1] || "");
2750
- index += 1;
2751
- continue;
2752
- }
2753
-
2754
- if (arg.startsWith("--service=")) {
2755
- options.services.push(arg.split("=")[1] || "");
2756
- continue;
2757
- }
2758
-
2759
- if (arg === "--services") {
2760
- options.services.push(...splitCsv(argv[index + 1] || ""));
2761
- index += 1;
2762
- continue;
2763
- }
2764
-
2765
- if (arg.startsWith("--services=")) {
2766
- options.services.push(...splitCsv(arg.split("=")[1] || ""));
2767
- continue;
2768
- }
2769
-
2770
1447
  positionals.push(arg);
2771
1448
  }
2772
1449
 
2773
1450
  options.directory = positionals[0] || options.directory;
2774
- options.services = [...new Set(options.services.map((service) => service.trim()).filter(Boolean))];
2775
1451
  return options;
2776
1452
  }
2777
1453
 
2778
- function parseDestroyRailwayArgs(argv) {
1454
+ function parseDiffRailwayConfigArgs(argv) {
2779
1455
  const options = {
1456
+ compareEnvironment: undefined,
1457
+ compareFile: undefined,
2780
1458
  directory: ".",
2781
- dryRun: false,
2782
1459
  environment: undefined,
2783
- scope: "environment",
2784
- twoFactorCode: undefined,
2785
- yes: false,
1460
+ file: undefined,
1461
+ json: false,
1462
+ showSecrets: false,
2786
1463
  };
2787
1464
  const positionals = [];
2788
1465
 
2789
1466
  for (let index = 0; index < argv.length; index += 1) {
2790
1467
  const arg = argv[index];
2791
1468
 
2792
- if (arg === "--yes" || arg === "-y") {
2793
- options.yes = true;
1469
+ if (arg === "--json") {
1470
+ options.json = true;
2794
1471
  continue;
2795
1472
  }
2796
1473
 
2797
- if (arg === "--dry-run") {
2798
- options.dryRun = true;
1474
+ if (arg === "--show-secrets") {
1475
+ options.showSecrets = true;
2799
1476
  continue;
2800
1477
  }
2801
1478
 
@@ -2810,25 +1487,36 @@ function parseDestroyRailwayArgs(argv) {
2810
1487
  continue;
2811
1488
  }
2812
1489
 
2813
- if (arg === "--scope") {
2814
- options.scope = argv[index + 1] || options.scope;
1490
+ if (arg === "--compare-environment") {
1491
+ options.compareEnvironment = argv[index + 1] || options.compareEnvironment;
1492
+ index += 1;
1493
+ continue;
1494
+ }
1495
+
1496
+ if (arg.startsWith("--compare-environment=")) {
1497
+ options.compareEnvironment = arg.split("=")[1] || options.compareEnvironment;
1498
+ continue;
1499
+ }
1500
+
1501
+ if (arg === "--file" || arg === "-f") {
1502
+ options.file = argv[index + 1] || options.file;
2815
1503
  index += 1;
2816
1504
  continue;
2817
1505
  }
2818
1506
 
2819
- if (arg.startsWith("--scope=")) {
2820
- options.scope = arg.split("=")[1] || options.scope;
1507
+ if (arg.startsWith("--file=")) {
1508
+ options.file = arg.split("=")[1] || options.file;
2821
1509
  continue;
2822
1510
  }
2823
1511
 
2824
- if (arg === "--2fa-code") {
2825
- options.twoFactorCode = argv[index + 1] || options.twoFactorCode;
1512
+ if (arg === "--compare-file") {
1513
+ options.compareFile = argv[index + 1] || options.compareFile;
2826
1514
  index += 1;
2827
1515
  continue;
2828
1516
  }
2829
1517
 
2830
- if (arg.startsWith("--2fa-code=")) {
2831
- options.twoFactorCode = arg.split("=")[1] || options.twoFactorCode;
1518
+ if (arg.startsWith("--compare-file=")) {
1519
+ options.compareFile = arg.split("=")[1] || options.compareFile;
2832
1520
  continue;
2833
1521
  }
2834
1522
 
@@ -2836,10 +1524,9 @@ function parseDestroyRailwayArgs(argv) {
2836
1524
  }
2837
1525
 
2838
1526
  options.directory = positionals[0] || options.directory;
2839
- if (!["environment", "project"].includes(options.scope)) {
2840
- throw new Error("--scope must be either 'environment' or 'project'");
1527
+ if (!options.compareEnvironment && !options.compareFile) {
1528
+ throw new Error("diff-railway-config requires --compare-environment <name> or --compare-file <snapshot.json>.");
2841
1529
  }
2842
-
2843
1530
  return options;
2844
1531
  }
2845
1532
 
@@ -3101,27 +1788,16 @@ async function ensureRailwayEnvironmentLinked(projectDir, environment) {
3101
1788
  }
3102
1789
 
3103
1790
  async function readRailwayManifest(projectDir) {
3104
- const manifestPath = path.join(projectDir, RAILWAY_MANIFEST_FILENAME);
3105
- if (!(await fs.pathExists(manifestPath))) {
3106
- return {
3107
- appServices: {},
3108
- bucket: DEFAULT_RAILWAY_BUCKET,
3109
- environmentId: null,
3110
- environmentName: null,
3111
- projectSlug: null,
3112
- projectId: null,
3113
- projectName: null,
3114
- resources: {},
3115
- updatedAt: null,
3116
- };
3117
- }
3118
-
3119
- return fs.readJson(manifestPath);
1791
+ return readRailwayManifestFile(projectDir, {
1792
+ defaultBucket: DEFAULT_RAILWAY_BUCKET,
1793
+ filename: RAILWAY_MANIFEST_FILENAME,
1794
+ });
3120
1795
  }
3121
1796
 
3122
1797
  async function writeRailwayManifest(projectDir, manifest) {
3123
- const manifestPath = path.join(projectDir, RAILWAY_MANIFEST_FILENAME);
3124
- await fs.writeJson(manifestPath, manifest, { spaces: 2 });
1798
+ await writeRailwayManifestFile(projectDir, manifest, {
1799
+ filename: RAILWAY_MANIFEST_FILENAME,
1800
+ });
3125
1801
  }
3126
1802
 
3127
1803
  async function loadRailwayContext(projectDir, environment) {
@@ -3171,55 +1847,11 @@ async function discoverRailwayServices(railwayContext, projectDir) {
3171
1847
  return cliServices;
3172
1848
  }
3173
1849
 
3174
- const auth = getRailwayApiAuth();
3175
- if (!auth || !railwayContext.projectId) {
1850
+ if (!railwayContext.projectId) {
3176
1851
  return [];
3177
1852
  }
3178
1853
 
3179
- try {
3180
- const response = await fetch(RAILWAY_GRAPHQL_ENDPOINT, {
3181
- body: JSON.stringify({
3182
- query: `query SetupRailwayServices($projectId: String!) {
3183
- project(id: $projectId) {
3184
- services {
3185
- edges {
3186
- node {
3187
- id
3188
- name
3189
- icon
3190
- }
3191
- }
3192
- }
3193
- }
3194
- }`,
3195
- variables: {
3196
- projectId: railwayContext.projectId,
3197
- },
3198
- }),
3199
- headers: {
3200
- ...auth.headers,
3201
- "Content-Type": "application/json",
3202
- },
3203
- method: "POST",
3204
- });
3205
-
3206
- if (!response.ok) {
3207
- return [];
3208
- }
3209
-
3210
- const payload = await response.json();
3211
- const nodes = payload?.data?.project?.services?.edges || [];
3212
- return nodes
3213
- .map((edge) => edge?.node)
3214
- .filter(Boolean)
3215
- .map((service) => ({
3216
- icon: typeof service.icon === "string" ? service.icon : null,
3217
- id: typeof service.id === "string" ? service.id : null,
3218
- name: typeof service.name === "string" ? service.name : null,
3219
- }));
3220
- } catch {
3221
- return [];
3222
- }
1854
+ return fetchRailwayProjectServices(railwayContext.projectId);
3223
1855
  }
3224
1856
 
3225
1857
  async function discoverRailwayServicesViaCli(railwayContext, projectDir) {
@@ -3248,26 +1880,6 @@ async function discoverRailwayServicesViaCli(railwayContext, projectDir) {
3248
1880
  }
3249
1881
  }
3250
1882
 
3251
- function getRailwayApiAuth() {
3252
- if (process.env.RAILWAY_API_TOKEN) {
3253
- return {
3254
- headers: {
3255
- Authorization: `Bearer ${process.env.RAILWAY_API_TOKEN}`,
3256
- },
3257
- };
3258
- }
3259
-
3260
- if (process.env.RAILWAY_TOKEN) {
3261
- return {
3262
- headers: {
3263
- "Project-Access-Token": process.env.RAILWAY_TOKEN,
3264
- },
3265
- };
3266
- }
3267
-
3268
- return null;
3269
- }
3270
-
3271
1883
  async function ensureRailwayResource(config) {
3272
1884
  const manifestEntry = config.manifest.resources?.[config.key];
3273
1885
  const existingService = findRailwayService(config.existingServices, config.aliases, manifestEntry?.serviceName);
@@ -3458,29 +2070,6 @@ function resolveDeployRailwaySpecs(selectedServices, availableSpecs = DEFAULT_RA
3458
2070
  return specs;
3459
2071
  }
3460
2072
 
3461
- function resolveRailwayAppServiceSpecs(projectConfig) {
3462
- const configuredServices = projectConfig?.railway?.services;
3463
- if (!Array.isArray(configuredServices) || configuredServices.length === 0) {
3464
- return DEFAULT_RAILWAY_APP_SERVICE_SPECS.map((spec) => ({ ...spec, aliases: [...spec.aliases] }));
3465
- }
3466
-
3467
- const specs = configuredServices.map((service, index) => normalizeRailwayAppServiceSpec(service, index));
3468
- const seenKeys = new Set();
3469
- for (const spec of specs) {
3470
- if (seenKeys.has(spec.key)) {
3471
- throw new Error(`Duplicate railway.services key \`${spec.key}\`.`);
3472
- }
3473
- seenKeys.add(spec.key);
3474
- }
3475
-
3476
- return specs;
3477
- }
3478
-
3479
- function getRailwayConfig(projectConfig) {
3480
- const railwayConfig = projectConfig?.railway;
3481
- return railwayConfig && typeof railwayConfig === "object" && !Array.isArray(railwayConfig) ? railwayConfig : {};
3482
- }
3483
-
3484
2073
  function listRailwayEnvironmentEntries(projectConfig) {
3485
2074
  const environments = getRailwayConfig(projectConfig).environments;
3486
2075
  if (!environments || typeof environments !== "object" || Array.isArray(environments)) {
@@ -3545,75 +2134,6 @@ function resolveRailwayEnvironmentSelection(projectConfig, requestedEnvironment,
3545
2134
  };
3546
2135
  }
3547
2136
 
3548
- function normalizeRailwayAppServiceSpec(input, index) {
3549
- if (!input || typeof input !== "object" || Array.isArray(input)) {
3550
- throw new Error(`Invalid railway.services entry at index ${index}.`);
3551
- }
3552
-
3553
- const key = slugify(String(input.key || "").trim());
3554
- const directory = String(input.directory || "").trim().replace(/^\.\//, "").replace(/\/+$/g, "");
3555
- const baseName = slugify(String(input.baseName || input.key || path.basename(directory) || "").trim());
3556
- const aliases = [
3557
- key,
3558
- baseName,
3559
- ...(Array.isArray(input.aliases) ? input.aliases : []),
3560
- ]
3561
- .map((value) => normalizeRailwayServiceName(value))
3562
- .filter(Boolean);
3563
-
3564
- if (!key) {
3565
- throw new Error("Each railway.services entry needs a non-empty `key`.");
3566
- }
3567
- if (!directory) {
3568
- throw new Error(`Railway service \`${key}\` needs a non-empty \`directory\`.`);
3569
- }
3570
- if (!baseName) {
3571
- throw new Error(`Railway service \`${key}\` needs a valid \`baseName\` or \`key\`.`);
3572
- }
3573
-
3574
- return {
3575
- aliases: [...new Set(aliases)],
3576
- baseName,
3577
- directory,
3578
- dockerfile: String(input.dockerfile || "").trim() || null,
3579
- key,
3580
- seedImage: String(input.seedImage || (key === "admin" ? "nginx:1.29-alpine" : "alpine:3.22")).trim(),
3581
- serviceName: String(input.serviceName || "").trim() || null,
3582
- };
3583
- }
3584
-
3585
- function resolveRailwayServiceName(spec, projectSlug) {
3586
- return spec.serviceName || buildRailwayAppServiceName(projectSlug, spec.baseName);
3587
- }
3588
-
3589
- function findRailwayAppServiceSpec(appServiceSpecs, key) {
3590
- const exact = appServiceSpecs.find((candidate) => candidate.key === key);
3591
- if (exact) {
3592
- return exact;
3593
- }
3594
-
3595
- const defaultSpec = DEFAULT_RAILWAY_APP_SERVICE_SPECS.find((candidate) => candidate.key === key);
3596
- if (!defaultSpec) {
3597
- return null;
3598
- }
3599
-
3600
- const defaultNames = [defaultSpec.key, defaultSpec.baseName, ...defaultSpec.aliases].map(normalizeRailwayServiceName);
3601
- return appServiceSpecs.find((candidate) => {
3602
- const names = [candidate.key, candidate.baseName, ...candidate.aliases].map(normalizeRailwayServiceName);
3603
- return names.some((name) => defaultNames.includes(name));
3604
- }) || null;
3605
- }
3606
-
3607
- function findRailwayServiceByKey(services, appServiceSpecs, manifest, key) {
3608
- const spec = findRailwayAppServiceSpec(appServiceSpecs, key);
3609
- if (!spec) {
3610
- return null;
3611
- }
3612
-
3613
- const preferredName = manifest.appServices?.[key]?.serviceName || resolveRailwayServiceName(spec, manifest.projectSlug);
3614
- return findRailwayService(services, spec.aliases, preferredName);
3615
- }
3616
-
3617
2137
  function resolveRailwayVariablesMode(projectConfig) {
3618
2138
  const mode = String(getRailwayConfig(projectConfig).variablesMode || "preserve-remote").trim().toLowerCase();
3619
2139
  if (!mode) {
@@ -3725,33 +2245,6 @@ function mergeRailwayServiceVariables(registryEntry, variables) {
3725
2245
  };
3726
2246
  }
3727
2247
 
3728
- function assignManagedRailwayServiceVariables(registryEntry, variables, variablesMode) {
3729
- if (!registryEntry || !registryEntry.name) {
3730
- return;
3731
- }
3732
-
3733
- const nextVariables = {};
3734
- const existingVariables = registryEntry.existingVariables || {};
3735
-
3736
- for (const [key, value] of Object.entries(variables || {})) {
3737
- if (typeof value !== "string" || value.length === 0) {
3738
- continue;
3739
- }
3740
-
3741
- if (
3742
- variablesMode === "preserve-remote" &&
3743
- Object.prototype.hasOwnProperty.call(existingVariables, key)
3744
- ) {
3745
- nextVariables[key] = existingVariables[key];
3746
- continue;
3747
- }
3748
-
3749
- nextVariables[key] = value;
3750
- }
3751
-
3752
- mergeRailwayServiceVariables(registryEntry, nextVariables);
3753
- }
3754
-
3755
2248
  async function hydrateRailwayServiceVariables(projectDir, environment, serviceRegistry) {
3756
2249
  await Promise.all(
3757
2250
  Object.values(serviceRegistry)
@@ -3823,7 +2316,7 @@ async function resolveRailwayVariablePlan(config) {
3823
2316
  const declaredVariables = resolveDeclaredRailwayVariables(config.projectConfig, config.environmentSelection);
3824
2317
  validateDeclaredRailwayVariableTargets(declaredVariables, serviceRegistry);
3825
2318
 
3826
- if (variablesMode === "preserve-remote") {
2319
+ if (variablesMode === "preserve-remote" || variablesMode === "sync-managed") {
3827
2320
  await hydrateRailwayServiceVariables(config.projectDir, config.railwayContext.environmentRef, serviceRegistry);
3828
2321
  }
3829
2322
 
@@ -3874,8 +2367,9 @@ async function resolveRailwayVariablePlan(config) {
3874
2367
  if (infra.objectStorage?.name) {
3875
2368
  Object.assign(variables, buildObjectStorageVariables(infra.objectStorage.name));
3876
2369
  }
3877
- if (appServices.admin?.name) {
3878
- variables.CORS_ALLOWED_ORIGINS = `https://${railwayReference(appServices.admin.name, "RAILWAY_PUBLIC_DOMAIN")}`;
2370
+ const browserOrigins = buildRailwayBrowserOrigins(appServices);
2371
+ if (browserOrigins) {
2372
+ variables.CORS_ALLOWED_ORIGINS = browserOrigins;
3879
2373
  }
3880
2374
  variables.PUBLIC_API_BASE_URL = `https://${railwayReference(appServices.api.name, "RAILWAY_PUBLIC_DOMAIN")}`;
3881
2375
  assignManagedRailwayServiceVariables(serviceRegistry.api, variables, variablesMode);
@@ -3890,8 +2384,9 @@ async function resolveRailwayVariablePlan(config) {
3890
2384
  if (appServices.api?.name) {
3891
2385
  variables.JWT_SECRET = railwayReference(appServices.api.name, "JWT_SECRET");
3892
2386
  }
3893
- if (appServices.admin?.name) {
3894
- variables.CORS_ALLOWED_ORIGINS = `https://${railwayReference(appServices.admin?.name || "admin", "RAILWAY_PUBLIC_DOMAIN")}`;
2387
+ const browserOrigins = buildRailwayBrowserOrigins(appServices);
2388
+ if (browserOrigins) {
2389
+ variables.CORS_ALLOWED_ORIGINS = browserOrigins;
3895
2390
  }
3896
2391
  assignManagedRailwayServiceVariables(serviceRegistry.realtime, variables, variablesMode);
3897
2392
  }
@@ -4103,7 +2598,7 @@ function buildAdminDefaults(localEnv) {
4103
2598
  return {
4104
2599
  VITE_API_TIMEOUT_MS: localEnv.admin.VITE_API_TIMEOUT_MS || "15000",
4105
2600
  VITE_APP_NAME: localEnv.admin.VITE_APP_NAME || "Admin Blueprint",
4106
- VITE_REALTIME_DEFAULT_TRANSPORT: localEnv.admin.VITE_REALTIME_DEFAULT_TRANSPORT || "sse",
2601
+ VITE_REALTIME_DEFAULT_TRANSPORT: localEnv.admin.VITE_REALTIME_DEFAULT_TRANSPORT || "ws",
4107
2602
  VITE_REALTIME_RECONNECT_DELAY_MS: localEnv.admin.VITE_REALTIME_RECONNECT_DELAY_MS || "3000",
4108
2603
  };
4109
2604
  }
@@ -4131,14 +2626,6 @@ function sanitizeSecret(value, placeholder) {
4131
2626
  return normalized;
4132
2627
  }
4133
2628
 
4134
- async function tryReadEnvFile(filePath) {
4135
- if (!(await fs.pathExists(filePath))) {
4136
- return {};
4137
- }
4138
-
4139
- return readEnvFile(filePath);
4140
- }
4141
-
4142
2629
  function buildObjectStorageVariables(serviceName) {
4143
2630
  return {
4144
2631
  MINIO_ACCESS_KEY: railwayReference(serviceName, "MINIO_ROOT_USER"),
@@ -4203,185 +2690,42 @@ async function applyRailwayVariables(config) {
4203
2690
  });
4204
2691
  }
4205
2692
 
4206
- function buildRailwayVariableDiff(currentVariables, nextVariables, options = {}) {
4207
- const changes = [];
4208
- const includeRemoved = options.includeRemoved ?? true;
4209
- const keys = [...new Set([...Object.keys(nextVariables || {}), ...(includeRemoved ? Object.keys(currentVariables || {}) : [])])].sort();
4210
- for (const key of keys) {
4211
- const rawCurrent = currentVariables?.[key];
4212
- const rawNext = nextVariables?.[key];
4213
- const currentValue = typeof rawCurrent === "string" ? rawCurrent : rawCurrent === undefined || rawCurrent === null ? undefined : String(rawCurrent);
4214
- const nextValue = typeof rawNext === "string" ? rawNext : rawNext === undefined || rawNext === null ? undefined : String(rawNext);
4215
- if (nextValue === undefined && currentValue === undefined) {
4216
- continue;
4217
- }
4218
-
4219
- let status = "unchanged";
4220
- if (currentValue === undefined) {
4221
- status = "added";
4222
- } else if (nextValue === undefined) {
4223
- status = "removed";
4224
- } else if (currentValue !== nextValue) {
4225
- status = "changed";
4226
- }
4227
-
4228
- changes.push({ currentValue, key, nextValue, status });
4229
- }
4230
-
4231
- return {
4232
- added: changes.filter((item) => item.status === "added"),
4233
- changed: changes.filter((item) => item.status === "changed"),
4234
- removed: changes.filter((item) => item.status === "removed"),
4235
- unchanged: changes.filter((item) => item.status === "unchanged"),
4236
- };
4237
- }
4238
-
4239
2693
  function printRailwayVariableDiff(serviceName, diff) {
4240
2694
  const counts = [`${diff.added.length} added`, `${diff.changed.length} changed`, `${diff.unchanged.length} unchanged`].join(", ");
4241
2695
  console.log(`- ${pc.cyan(serviceName)} diff: ${counts}`);
4242
-
4243
- for (const item of [...diff.added, ...diff.changed]) {
4244
- const before = item.currentValue === undefined ? "<unset>" : formatRailwayVariableValue(item.key, item.currentValue);
4245
- const after = formatRailwayVariableValue(item.key, item.nextValue);
4246
- console.log(` ${item.key}: ${before} -> ${after}`);
4247
- }
4248
- }
4249
-
4250
- function sanitizeVariablesForOutput(variables, showSecrets = false) {
4251
- const sanitized = {};
4252
- for (const [key, value] of Object.entries(variables)) {
4253
- sanitized[key] = formatRailwayVariableValue(key, value, showSecrets);
4254
- }
4255
- return sanitized;
4256
- }
4257
-
4258
- function formatRailwayVariableValue(key, value, showSecrets = false) {
4259
- if (value === undefined) {
4260
- return "<unset>";
4261
- }
4262
-
4263
- const normalizedKey = String(key || "").toUpperCase();
4264
- if (!showSecrets && /(SECRET|PASSWORD|TOKEN|API_KEY|ACCESS_KEY|SECRET_KEY|ERLANG_COOKIE)/.test(normalizedKey)) {
4265
- return redactRailwayVariableValue(value);
4266
- }
4267
-
4268
- return String(value);
4269
- }
4270
-
4271
- function redactRailwayVariableValue(value) {
4272
- const textValue = String(value || "");
4273
- if (textValue.length <= 8) {
4274
- return "[redacted]";
4275
- }
4276
- return `${textValue.slice(0, 3)}...[redacted]...${textValue.slice(-2)}`;
4277
- }
4278
-
4279
- function printResolvedRailwayConfig(payload) {
4280
- console.log(pc.bold("\nRailway config"));
4281
- console.log(`- Directory: ${pc.bold(payload.directory)}`);
4282
- console.log(`- Variables mode: ${pc.bold(payload.variablesMode)}`);
4283
- console.log(`- Environment key: ${pc.bold(payload.environment.configKey || "<none>")}`);
4284
- console.log(`- Railway environment: ${pc.bold(payload.environment.railwayEnvironment || "<linked default>")}`);
4285
-
4286
- console.log(pc.bold("\nServices"));
4287
- for (const service of payload.services) {
4288
- console.log(`- ${pc.bold(service.key)} -> ${service.serviceName}`);
4289
- console.log(` directory: ${service.directory}`);
4290
- if (service.dockerfile) {
4291
- console.log(` dockerfile: ${service.dockerfile}`);
4292
- }
4293
- console.log(` aliases: ${service.aliases.join(", ")}`);
4294
- const variableEntries = Object.entries(service.variables || {});
4295
- if (variableEntries.length === 0) {
4296
- console.log(" variables: <none>");
4297
- continue;
4298
- }
4299
- console.log(" variables:");
4300
- for (const [key, value] of variableEntries.sort(([left], [right]) => left.localeCompare(right))) {
4301
- console.log(` ${key}=${value}`);
4302
- }
4303
- }
4304
- }
4305
-
4306
- function buildRailwayConfigExportFilename(payload) {
4307
- const envSuffix = slugify(payload.environment.configKey || payload.environment.railwayEnvironment || "default") || "default";
4308
- return path.join(payload.directory, `.railway-config.${envSuffix}.json`);
4309
- }
4310
-
4311
- async function readRailwayConfigSnapshot(filePath) {
4312
- const absolutePath = path.resolve(process.cwd(), filePath);
4313
- if (!(await fs.pathExists(absolutePath))) {
4314
- throw new Error(`Railway config snapshot not found: ${absolutePath}`);
4315
- }
4316
-
4317
- const payload = await fs.readJson(absolutePath);
4318
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
4319
- throw new Error(`Railway config snapshot is invalid: ${absolutePath}`);
4320
- }
4321
-
4322
- if (!Array.isArray(payload.services)) {
4323
- throw new Error(`Railway config snapshot is missing a services array: ${absolutePath}`);
4324
- }
4325
-
4326
- return payload;
4327
- }
4328
-
4329
- function assertRailwaySnapshotImportable(snapshot) {
4330
- const redactedEntries = [];
4331
- for (const service of snapshot.services || []) {
4332
- for (const [key, value] of Object.entries(service.variables || {})) {
4333
- if (String(value || "").includes("[redacted]")) {
4334
- redactedEntries.push(`${service.key}.${key}`);
4335
- }
4336
- }
4337
- }
4338
-
4339
- if (redactedEntries.length > 0) {
4340
- throw new Error(
4341
- `Snapshot contains redacted values and cannot be imported safely. Re-export with --show-secrets. Problem keys: ${redactedEntries.join(", ")}`,
4342
- );
4343
- }
4344
- }
4345
-
4346
- function buildRailwayConfigSnapshotDiff(left, right) {
4347
- const leftServices = new Map((left.services || []).map((service) => [service.key, service]));
4348
- const rightServices = new Map((right.services || []).map((service) => [service.key, service]));
4349
- const serviceKeys = [...new Set([...leftServices.keys(), ...rightServices.keys()])].sort();
4350
-
4351
- const services = serviceKeys.map((key) => {
4352
- const leftService = leftServices.get(key) || null;
4353
- const rightService = rightServices.get(key) || null;
4354
- return {
4355
- key,
4356
- metadata: {
4357
- directory: buildRailwayFieldDiff(leftService?.directory, rightService?.directory),
4358
- dockerfile: buildRailwayFieldDiff(leftService?.dockerfile, rightService?.dockerfile),
4359
- serviceName: buildRailwayFieldDiff(leftService?.serviceName, rightService?.serviceName),
4360
- },
4361
- status: !leftService ? "added" : !rightService ? "removed" : "present",
4362
- variables: buildRailwayVariableDiff(leftService?.variables || {}, rightService?.variables || {}),
4363
- };
4364
- });
4365
-
4366
- return {
4367
- left: {
4368
- directory: left.directory,
4369
- environment: left.environment,
4370
- },
4371
- right: {
4372
- directory: right.directory,
4373
- environment: right.environment,
4374
- },
4375
- services,
4376
- };
2696
+
2697
+ for (const item of [...diff.added, ...diff.changed]) {
2698
+ const before = item.currentValue === undefined ? "<unset>" : formatRailwayVariableValue(item.key, item.currentValue);
2699
+ const after = formatRailwayVariableValue(item.key, item.nextValue);
2700
+ console.log(` ${item.key}: ${before} -> ${after}`);
2701
+ }
4377
2702
  }
4378
2703
 
4379
- function buildRailwayFieldDiff(leftValue, rightValue) {
4380
- return {
4381
- left: leftValue ?? null,
4382
- right: rightValue ?? null,
4383
- status: leftValue === rightValue ? "unchanged" : leftValue === undefined ? "added" : rightValue === undefined ? "removed" : "changed",
4384
- };
2704
+ function printResolvedRailwayConfig(payload) {
2705
+ console.log(pc.bold("\nRailway config"));
2706
+ console.log(`- Directory: ${pc.bold(payload.directory)}`);
2707
+ console.log(`- Variables mode: ${pc.bold(payload.variablesMode)}`);
2708
+ console.log(`- Environment key: ${pc.bold(payload.environment.configKey || "<none>")}`);
2709
+ console.log(`- Railway environment: ${pc.bold(payload.environment.railwayEnvironment || "<linked default>")}`);
2710
+
2711
+ console.log(pc.bold("\nServices"));
2712
+ for (const service of payload.services) {
2713
+ console.log(`- ${pc.bold(service.key)} -> ${service.serviceName}`);
2714
+ console.log(` directory: ${service.directory}`);
2715
+ if (service.dockerfile) {
2716
+ console.log(` dockerfile: ${service.dockerfile}`);
2717
+ }
2718
+ console.log(` aliases: ${service.aliases.join(", ")}`);
2719
+ const variableEntries = Object.entries(service.variables || {});
2720
+ if (variableEntries.length === 0) {
2721
+ console.log(" variables: <none>");
2722
+ continue;
2723
+ }
2724
+ console.log(" variables:");
2725
+ for (const [key, value] of variableEntries.sort(([left], [right]) => left.localeCompare(right))) {
2726
+ console.log(` ${key}=${value}`);
2727
+ }
2728
+ }
4385
2729
  }
4386
2730
 
4387
2731
  function printRailwayConfigSnapshotDiff(diff) {
@@ -4405,33 +2749,6 @@ function printRailwayConfigSnapshotDiff(diff) {
4405
2749
  }
4406
2750
  }
4407
2751
 
4408
- function sanitizeRailwayConfigSnapshotDiff(diff, showSecrets) {
4409
- if (showSecrets) {
4410
- return diff;
4411
- }
4412
-
4413
- return {
4414
- ...diff,
4415
- services: diff.services.map((service) => ({
4416
- ...service,
4417
- variables: {
4418
- added: service.variables.added.map(sanitizeRailwayDiffEntry),
4419
- changed: service.variables.changed.map(sanitizeRailwayDiffEntry),
4420
- removed: service.variables.removed.map(sanitizeRailwayDiffEntry),
4421
- unchanged: service.variables.unchanged.map(sanitizeRailwayDiffEntry),
4422
- },
4423
- })),
4424
- };
4425
- }
4426
-
4427
- function sanitizeRailwayDiffEntry(entry) {
4428
- return {
4429
- ...entry,
4430
- currentValue: formatRailwayVariableValue(entry.key, entry.currentValue),
4431
- nextValue: formatRailwayVariableValue(entry.key, entry.nextValue),
4432
- };
4433
- }
4434
-
4435
2752
  function formatRailwayDiffSide(side) {
4436
2753
  const env = side.environment?.configKey || side.environment?.railwayEnvironment || "default";
4437
2754
  return `${side.directory || "<snapshot>"} (${env})`;
@@ -4504,80 +2821,6 @@ async function waitForCreatedRailwayService(config) {
4504
2821
  );
4505
2822
  }
4506
2823
 
4507
- function findRailwayService(services, aliases, preferredName) {
4508
- if (preferredName) {
4509
- const exact = services.find(
4510
- (service) => normalizeRailwayServiceName(service.name) === normalizeRailwayServiceName(preferredName),
4511
- );
4512
- if (exact) {
4513
- return exact;
4514
- }
4515
- }
4516
-
4517
- const normalizedAliases = aliases.map(normalizeRailwayServiceName);
4518
- return services.find((service) => {
4519
- const normalizedName = normalizeRailwayServiceName(service.name);
4520
- return normalizedAliases.some((alias) => normalizedName === alias || normalizedName.endsWith(`-${alias}`));
4521
- });
4522
- }
4523
-
4524
- function normalizeRailwayServiceName(value) {
4525
- return String(value || "")
4526
- .trim()
4527
- .toLowerCase()
4528
- .replace(/[^a-z0-9]+/g, "-")
4529
- .replace(/^-+|-+$/g, "");
4530
- }
4531
-
4532
- function normalizeRailwayServices(services) {
4533
- const seen = new Set();
4534
- const normalized = [];
4535
-
4536
- for (const service of services) {
4537
- const name = pickFirstString([service.name, service.serviceName]);
4538
- if (!name) {
4539
- continue;
4540
- }
4541
-
4542
- const id = pickFirstString([service.id, service.serviceId]);
4543
- const key = `${normalizeRailwayServiceName(name)}:${id || ""}`;
4544
- if (seen.has(key)) {
4545
- continue;
4546
- }
4547
-
4548
- seen.add(key);
4549
- normalized.push({ id, name });
4550
- }
4551
-
4552
- return normalized;
4553
- }
4554
-
4555
- function findCreatedRailwayService(config) {
4556
- const beforeServices = normalizeRailwayServices(config.beforeServices);
4557
- const afterServices = normalizeRailwayServices(config.servicesAfter);
4558
- const beforeKeys = new Set(beforeServices.map(createRailwayServiceIdentity));
4559
- const newServices = afterServices.filter((service) => !beforeKeys.has(createRailwayServiceIdentity(service)));
4560
-
4561
- if (newServices.length === 1) {
4562
- return newServices[0];
4563
- }
4564
-
4565
- const aliasMatch = findRailwayService(newServices, config.aliases, config.manifestServiceName);
4566
- if (aliasMatch) {
4567
- return aliasMatch;
4568
- }
4569
-
4570
- return null;
4571
- }
4572
-
4573
- function createRailwayServiceIdentity(service) {
4574
- if (service?.id) {
4575
- return `id:${service.id}`;
4576
- }
4577
-
4578
- return `name:${normalizeRailwayServiceName(service?.name)}`;
4579
- }
4580
-
4581
2824
  function normalizeRailwayVariables(input) {
4582
2825
  const normalized = {};
4583
2826
 
@@ -4598,27 +2841,6 @@ function normalizeRailwayVariables(input) {
4598
2841
  return normalized;
4599
2842
  }
4600
2843
 
4601
- function updateRailwayManifestAppServices(manifest, services, appServiceSpecs, projectSlug) {
4602
- manifest.appServices ||= {};
4603
-
4604
- for (const spec of appServiceSpecs) {
4605
- const key = spec.key;
4606
- const service = findRailwayService(
4607
- services,
4608
- spec.aliases,
4609
- manifest.appServices[key]?.serviceName || resolveRailwayServiceName(spec, projectSlug || manifest.projectSlug),
4610
- );
4611
- if (!service?.name) {
4612
- continue;
4613
- }
4614
-
4615
- manifest.appServices[key] = {
4616
- serviceId: service.id || manifest.appServices[key]?.serviceId || null,
4617
- serviceName: service.name,
4618
- };
4619
- }
4620
- }
4621
-
4622
2844
  function extractRailwayServiceCandidates(input) {
4623
2845
  const candidates = [];
4624
2846
 
@@ -4777,314 +2999,48 @@ function printRailwaySetupSummary(config) {
4777
2999
  if (config.variableSummary.length === 0) {
4778
3000
  console.log("- No application variables were updated");
4779
3001
  } else {
4780
- for (const item of config.variableSummary) {
4781
- console.log(`- ${pc.bold(item.serviceName)}: ${item.status} ${item.keys.join(", ")}`);
4782
- }
4783
- }
4784
-
4785
- if (config.dryRun) {
4786
- console.log(`- Dry run only, ${pc.bold(RAILWAY_MANIFEST_FILENAME)} was not written`);
4787
- } else {
4788
- console.log(`- Manifest written to ${pc.bold(RAILWAY_MANIFEST_FILENAME)} for future runs`);
4789
- }
4790
-
4791
- if (!getRailwayApiAuth()) {
4792
- console.log(pc.yellow("\nNote: Set RAILWAY_API_TOKEN or RAILWAY_TOKEN to let future runs verify remote services before provisioning."));
4793
- }
4794
- }
4795
-
4796
- function printRailwayDeploySummary(config) {
4797
- console.log(pc.bold("\nRailway deploy"));
4798
- console.log(`- Directory: ${pc.bold(config.projectDir)}`);
4799
- if (config.railwayContext.projectName || config.railwayContext.projectId) {
4800
- console.log(`- Project: ${pc.bold(config.railwayContext.projectName || config.railwayContext.projectId)}`);
4801
- }
4802
- if (config.railwayContext.environmentName || config.railwayContext.environmentId) {
4803
- console.log(`- Environment: ${pc.bold(config.railwayContext.environmentName || config.railwayContext.environmentId)}`);
4804
- }
4805
- console.log(`- Default managed services: ${pc.bold("api, worker, realtime-gateway, admin")}`);
4806
- console.log(`- Services: ${pc.bold(config.selectedServices.join(", "))}`);
4807
-
4808
- console.log(pc.bold("\nDeployments"));
4809
- if (config.deploySummary.length === 0) {
4810
- console.log("- No application deployments were triggered");
4811
- } else {
4812
- for (const item of config.deploySummary) {
4813
- console.log(`- ${pc.bold(item.serviceName)}: ${item.status} from ${item.directory}/`);
4814
- }
4815
- }
4816
-
4817
- if (config.dryRun) {
4818
- console.log(`- Dry run only, ${pc.bold(RAILWAY_MANIFEST_FILENAME)} was not written`);
4819
- } else {
4820
- console.log(`- Manifest written to ${pc.bold(RAILWAY_MANIFEST_FILENAME)} for future runs`);
4821
- }
4822
- }
4823
-
4824
- function parseStartArgs(argv) {
4825
- const options = {
4826
- directory: ".",
4827
- installDependencies: undefined,
4828
- profile: undefined,
4829
- startInfra: undefined,
4830
- yes: false,
4831
- };
4832
- const positionals = [];
4833
-
4834
- for (let index = 0; index < argv.length; index += 1) {
4835
- const arg = argv[index];
4836
-
4837
- if (arg === "--yes" || arg === "-y") {
4838
- options.yes = true;
4839
- continue;
4840
- }
4841
-
4842
- if (arg === "--install") {
4843
- options.installDependencies = true;
4844
- continue;
4845
- }
4846
-
4847
- if (arg === "--skip-install") {
4848
- options.installDependencies = false;
4849
- continue;
4850
- }
4851
-
4852
- if (arg === "--start-infra") {
4853
- options.startInfra = true;
4854
- continue;
4855
- }
4856
-
4857
- if (arg === "--skip-infra") {
4858
- options.startInfra = false;
4859
- continue;
4860
- }
4861
-
4862
- if (arg === "--profile") {
4863
- options.profile = argv[index + 1] || options.profile;
4864
- index += 1;
4865
- continue;
4866
- }
4867
-
4868
- if (arg.startsWith("--profile=")) {
4869
- options.profile = arg.split("=")[1] || options.profile;
4870
- continue;
4871
- }
4872
-
4873
- if (arg === "--skip-api") {
4874
- options.api = false;
4875
- continue;
4876
- }
4877
-
4878
- if (arg === "--skip-worker") {
4879
- options.worker = false;
4880
- continue;
4881
- }
4882
-
4883
- if (arg === "--skip-realtime") {
4884
- options.realtime = false;
4885
- continue;
4886
- }
4887
-
4888
- if (arg === "--skip-admin") {
4889
- options.admin = false;
4890
- continue;
4891
- }
4892
-
4893
- if (arg === "--skip-landing") {
4894
- options.landing = false;
4895
- continue;
4896
- }
4897
-
4898
- if (arg === "--skip-pwa") {
4899
- options.pwa = false;
4900
- continue;
4901
- }
4902
-
4903
- positionals.push(arg);
4904
- }
4905
-
4906
- options.directory = positionals[0] || options.directory;
4907
- return options;
4908
- }
4909
-
4910
- async function collectStartAnswers(args) {
4911
- if (args.yes) {
4912
- return {
4913
- directory: args.directory,
4914
- installDependencies: args.installDependencies ?? false,
4915
- profile: args.profile || inferProfileFromArgs(args) || "full",
4916
- selectedServices: buildSelectedServices(args),
4917
- startInfra: args.startInfra ?? true,
4918
- };
4919
- }
4920
-
4921
- const directory = await prompt(
4922
- text({
4923
- defaultValue: args.directory,
4924
- message: "Project directory to start?",
4925
- placeholder: ".",
4926
- validate(value) {
4927
- return value.trim().length === 0 ? "Project directory is required" : undefined;
4928
- },
4929
- }),
4930
- );
4931
-
4932
- const installDependencies = await prompt(
4933
- confirm({
4934
- initialValue: args.installDependencies ?? false,
4935
- message: "Install dependencies before start?",
4936
- }),
4937
- );
4938
-
4939
- const startInfra = await prompt(
4940
- confirm({
4941
- initialValue: args.startInfra ?? true,
4942
- message: "Start local Docker services?",
4943
- }),
4944
- );
4945
-
4946
- const profile = await prompt(
4947
- select({
4948
- initialValue: inferProfileFromArgs(args) || "full",
4949
- message: "Startup profile?",
4950
- options: [
4951
- { label: "Full stack", value: "full" },
4952
- { label: "Backend only", value: "backend-only" },
4953
- { label: "Frontend only", value: "frontend-only" },
4954
- { label: "Custom", value: "custom" },
4955
- ],
4956
- }),
4957
- );
4958
-
4959
- if (profile !== "custom") {
4960
- return {
4961
- directory,
4962
- installDependencies,
4963
- profile,
4964
- selectedServices: profileToServices(profile),
4965
- startInfra,
4966
- };
4967
- }
4968
-
4969
- const api = await prompt(
4970
- confirm({
4971
- initialValue: args.api ?? true,
4972
- message: "Start API server?",
4973
- }),
4974
- );
4975
- const worker = await prompt(
4976
- confirm({
4977
- initialValue: args.worker ?? true,
4978
- message: "Start API worker?",
4979
- }),
4980
- );
4981
- const realtime = await prompt(
4982
- confirm({
4983
- initialValue: args.realtime ?? true,
4984
- message: "Start realtime gateway?",
4985
- }),
4986
- );
4987
- const admin = await prompt(
4988
- confirm({
4989
- initialValue: args.admin ?? true,
4990
- message: "Start admin frontend?",
4991
- }),
4992
- );
4993
- const landing = await prompt(
4994
- confirm({
4995
- initialValue: args.landing ?? true,
4996
- message: "Start landing surface?",
4997
- }),
4998
- );
4999
- const pwa = await prompt(
5000
- confirm({
5001
- initialValue: args.pwa ?? true,
5002
- message: "Start PWA surface?",
5003
- }),
5004
- );
5005
-
5006
- return {
5007
- directory,
5008
- installDependencies,
5009
- profile: "custom",
5010
- selectedServices: [api && "api", worker && "worker", realtime && "realtime", admin && "admin", landing && "landing", pwa && "pwa"].filter(Boolean),
5011
- startInfra,
5012
- };
5013
- }
5014
-
5015
- function buildSelectedServices(args) {
5016
- if (args.profile) {
5017
- return applyServiceOverrides(profileToServices(args.profile), args);
5018
- }
5019
-
5020
- return applyServiceOverrides(profileToServices("full"), args);
5021
- }
5022
-
5023
- function inferProfileFromArgs(args) {
5024
- const selected = [
5025
- args.api !== false && "api",
5026
- args.worker !== false && "worker",
5027
- args.realtime !== false && "realtime",
5028
- args.admin !== false && "admin",
5029
- args.landing !== false && "landing",
5030
- args.pwa !== false && "pwa",
5031
- ].filter(Boolean);
5032
-
5033
- if (selected.length === 6) {
5034
- return "full";
3002
+ for (const item of config.variableSummary) {
3003
+ console.log(`- ${pc.bold(item.serviceName)}: ${item.status} ${item.keys.join(", ")}`);
3004
+ }
5035
3005
  }
5036
3006
 
5037
- if (arraysEqual(selected, profileToServices("backend-only"))) {
5038
- return "backend-only";
3007
+ if (config.dryRun) {
3008
+ console.log(`- Dry run only, ${pc.bold(RAILWAY_MANIFEST_FILENAME)} was not written`);
3009
+ } else {
3010
+ console.log(`- Manifest written to ${pc.bold(RAILWAY_MANIFEST_FILENAME)} for future runs`);
5039
3011
  }
5040
3012
 
5041
- if (arraysEqual(selected, profileToServices("frontend-only"))) {
5042
- return "frontend-only";
3013
+ if (!getRailwayApiAuth()) {
3014
+ console.log(pc.yellow("\nNote: Set RAILWAY_API_TOKEN or RAILWAY_TOKEN to let future runs verify remote services before provisioning."));
5043
3015
  }
5044
-
5045
- return undefined;
5046
3016
  }
5047
3017
 
5048
- function profileToServices(profile) {
5049
- switch (profile) {
5050
- case "backend-only":
5051
- return ["api", "worker", "realtime"];
5052
- case "frontend-only":
5053
- return ["admin", "landing", "pwa"];
5054
- case "custom":
5055
- return [];
5056
- case "full":
5057
- default:
5058
- return ["api", "worker", "realtime", "admin", "landing", "pwa"];
3018
+ function printRailwayDeploySummary(config) {
3019
+ console.log(pc.bold("\nRailway deploy"));
3020
+ console.log(`- Directory: ${pc.bold(config.projectDir)}`);
3021
+ if (config.railwayContext.projectName || config.railwayContext.projectId) {
3022
+ console.log(`- Project: ${pc.bold(config.railwayContext.projectName || config.railwayContext.projectId)}`);
5059
3023
  }
5060
- }
5061
-
5062
- function applyServiceOverrides(services, args) {
5063
- return services.filter((service) => args[service] !== false);
5064
- }
5065
-
5066
- function arraysEqual(left, right) {
5067
- return left.length === right.length && left.every((value, index) => value === right[index]);
5068
- }
5069
-
5070
- async function ensureProjectStructure(projectDir) {
5071
- const requiredPaths = ["admin", "api", "realtime-gateway", "docker-compose.yml"];
3024
+ if (config.railwayContext.environmentName || config.railwayContext.environmentId) {
3025
+ console.log(`- Environment: ${pc.bold(config.railwayContext.environmentName || config.railwayContext.environmentId)}`);
3026
+ }
3027
+ console.log(`- Default managed services: ${pc.bold("api, worker, realtime-gateway, admin")}`);
3028
+ console.log(`- Services: ${pc.bold(config.selectedServices.join(", "))}`);
5072
3029
 
5073
- for (const relativePath of requiredPaths) {
5074
- const target = path.join(projectDir, relativePath);
5075
- if (!(await fs.pathExists(target))) {
5076
- throw new Error(`Project root not recognized, missing ${relativePath} in ${projectDir}`);
3030
+ console.log(pc.bold("\nDeployments"));
3031
+ if (config.deploySummary.length === 0) {
3032
+ console.log("- No application deployments were triggered");
3033
+ } else {
3034
+ for (const item of config.deploySummary) {
3035
+ console.log(`- ${pc.bold(item.serviceName)}: ${item.status} from ${item.directory}/`);
5077
3036
  }
5078
3037
  }
5079
- }
5080
3038
 
5081
- async function loadProjectConfig(projectDir) {
5082
- const configPath = path.join(projectDir, "asaje.config.json");
5083
- if (!(await fs.pathExists(configPath))) {
5084
- return null;
3039
+ if (config.dryRun) {
3040
+ console.log(`- Dry run only, ${pc.bold(RAILWAY_MANIFEST_FILENAME)} was not written`);
3041
+ } else {
3042
+ console.log(`- Manifest written to ${pc.bold(RAILWAY_MANIFEST_FILENAME)} for future runs`);
5085
3043
  }
5086
-
5087
- return fs.readJson(configPath);
5088
3044
  }
5089
3045
 
5090
3046
  async function ensureRailwayAppServiceTargets(projectDir, appServiceSpecs) {
@@ -5103,191 +3059,6 @@ async function ensureRailwayAppServiceTargets(projectDir, appServiceSpecs) {
5103
3059
  }
5104
3060
  }
5105
3061
 
5106
- async function scanProjectForManagedRailwayServices(projectDir) {
5107
- const dockerfiles = [];
5108
- await collectDockerfiles(projectDir, "", dockerfiles);
5109
-
5110
- const scannedSpecs = dockerfiles
5111
- .map((dockerfilePath) => buildScannedRailwayServiceSpec(projectDir, dockerfilePath))
5112
- .filter(Boolean)
5113
- .sort((left, right) => left.directory.localeCompare(right.directory));
5114
-
5115
- const serviceSpecs = synthesizeDerivedRailwayServices(scannedSpecs);
5116
-
5117
- return {
5118
- dockerfiles,
5119
- serviceSpecs,
5120
- };
5121
- }
5122
-
5123
- function synthesizeDerivedRailwayServices(serviceSpecs) {
5124
- const nextSpecs = [...serviceSpecs];
5125
- const hasAPI = serviceSpecs.some((spec) => spec.key === "api" && spec.directory === "api");
5126
- const hasWorker = serviceSpecs.some((spec) => spec.key === "worker");
5127
- if (hasAPI && !hasWorker) {
5128
- nextSpecs.push({
5129
- aliases: ["worker", "api-worker"],
5130
- baseName: "worker",
5131
- directory: "api",
5132
- dockerfile: "api/Dockerfile",
5133
- key: "worker",
5134
- seedImage: "alpine:3.22",
5135
- serviceName: null,
5136
- });
5137
- }
5138
-
5139
- return nextSpecs.sort((left, right) => `${left.directory}:${left.key}`.localeCompare(`${right.directory}:${right.key}`));
5140
- }
5141
-
5142
- async function collectDockerfiles(projectDir, relativeDir, results) {
5143
- const absoluteDir = path.join(projectDir, relativeDir);
5144
- const entries = await fs.readdir(absoluteDir, { withFileTypes: true });
5145
-
5146
- for (const entry of entries) {
5147
- const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entry.name) : entry.name;
5148
- if (entry.isDirectory()) {
5149
- if (shouldSkipProjectScanDirectory(entry.name, nextRelativePath)) {
5150
- continue;
5151
- }
5152
-
5153
- await collectDockerfiles(projectDir, nextRelativePath, results);
5154
- continue;
5155
- }
5156
-
5157
- if (entry.isFile() && entry.name === "Dockerfile") {
5158
- results.push(nextRelativePath);
5159
- }
5160
- }
5161
- }
5162
-
5163
- function shouldSkipProjectScanDirectory(name, relativePath) {
5164
- const normalized = String(name || "").trim();
5165
- if (!normalized) {
5166
- return false;
5167
- }
5168
-
5169
- if ([".git", ".turbo", ".next", ".nuxt", "node_modules", "dist", "build", "coverage", "tmp", "vendor"].includes(normalized)) {
5170
- return true;
5171
- }
5172
-
5173
- if (relativePath === "cli") {
5174
- return true;
5175
- }
5176
-
5177
- return normalized.startsWith(".") && normalized !== ".well-known";
5178
- }
5179
-
5180
- function buildScannedRailwayServiceSpec(projectDir, dockerfilePath) {
5181
- const directory = path.posix.dirname(dockerfilePath);
5182
- if (!directory || directory === ".") {
5183
- return null;
5184
- }
5185
-
5186
- const inferred = inferRailwayServiceIdentity(directory);
5187
- return {
5188
- aliases: inferred.aliases,
5189
- baseName: inferred.baseName,
5190
- directory,
5191
- dockerfile: dockerfilePath,
5192
- key: inferred.key,
5193
- seedImage: inferred.key === "admin" ? "nginx:1.29-alpine" : "alpine:3.22",
5194
- serviceName: null,
5195
- };
5196
- }
5197
-
5198
- function inferRailwayServiceIdentity(directory) {
5199
- const normalizedDirectory = directory.replace(/\/+$/g, "");
5200
- const directoryName = path.posix.basename(normalizedDirectory);
5201
- const normalizedName = normalizeRailwayServiceName(directoryName);
5202
-
5203
- if (["api", "backend", "server"].includes(normalizedName)) {
5204
- return { aliases: ["api", "backend", "server"], baseName: "api", key: "api" };
5205
- }
5206
- if (["admin", "frontend", "web"].includes(normalizedName)) {
5207
- return { aliases: ["admin", "frontend", "web"], baseName: "admin", key: "admin" };
5208
- }
5209
- if (["realtime", "realtime-gateway"].includes(normalizedName)) {
5210
- return { aliases: ["realtime-gateway", "realtime"], baseName: "realtime-gateway", key: "realtime" };
5211
- }
5212
-
5213
- const slug = slugify(directoryName);
5214
- return { aliases: [slug], baseName: slug, key: slug };
5215
- }
5216
-
5217
- function buildSyncedProjectConfig(projectDir, projectConfig, scannedServiceSpecs) {
5218
- const nextConfig = {
5219
- ...(projectConfig || {}),
5220
- projectName: projectConfig?.projectName || path.basename(projectDir),
5221
- projectSlug: projectConfig?.projectSlug || slugify(path.basename(projectDir)),
5222
- };
5223
-
5224
- const previousServices = resolveRailwayAppServiceSpecs(projectConfig);
5225
- const mergedServices = mergeScannedRailwayServices(previousServices, scannedServiceSpecs);
5226
-
5227
- nextConfig.railway = {
5228
- ...(getRailwayConfig(projectConfig) || {}),
5229
- services: mergedServices.map((service) => ({
5230
- baseName: service.baseName,
5231
- directory: service.directory,
5232
- ...(service.dockerfile ? { dockerfile: service.dockerfile } : {}),
5233
- key: service.key,
5234
- ...(service.aliases?.length > 0 ? { aliases: service.aliases } : {}),
5235
- ...(service.serviceName ? { serviceName: service.serviceName } : {}),
5236
- ...(service.seedImage ? { seedImage: service.seedImage } : {}),
5237
- })),
5238
- };
5239
-
5240
- return nextConfig;
5241
- }
5242
-
5243
- function mergeScannedRailwayServices(previousServices, scannedServiceSpecs) {
5244
- const previousByKey = new Map(previousServices.map((service) => [service.key, service]));
5245
- const previousByDirectory = new Map(previousServices.map((service) => [service.directory, service]));
5246
-
5247
- return scannedServiceSpecs.map((scanned) => {
5248
- const previous = previousByKey.get(scanned.key) || previousByDirectory.get(scanned.directory);
5249
- return {
5250
- aliases: uniqueStrings([...(previous?.aliases || []), ...scanned.aliases]),
5251
- baseName: previous?.baseName || scanned.baseName,
5252
- directory: scanned.directory,
5253
- dockerfile: scanned.dockerfile,
5254
- key: previous?.key || scanned.key,
5255
- seedImage: previous?.seedImage || scanned.seedImage,
5256
- serviceName: previous?.serviceName || null,
5257
- };
5258
- });
5259
- }
5260
-
5261
- function buildSyncedRailwayManifest(manifest, nextProjectConfig, scannedServiceSpecs) {
5262
- const nextManifest = {
5263
- ...(manifest || {}),
5264
- appServices: {},
5265
- projectSlug: nextProjectConfig.projectSlug || manifest?.projectSlug || null,
5266
- updatedAt: new Date().toISOString(),
5267
- };
5268
-
5269
- const previousAppServices = manifest?.appServices || {};
5270
- const existingSpecs = resolveRailwayAppServiceSpecs(nextProjectConfig);
5271
- for (const spec of existingSpecs) {
5272
- const previousEntry =
5273
- previousAppServices[spec.key] ||
5274
- findRailwayManifestAppServiceByName(previousAppServices, resolveRailwayServiceName(spec, nextManifest.projectSlug));
5275
-
5276
- nextManifest.appServices[spec.key] = {
5277
- serviceId: previousEntry?.serviceId || null,
5278
- serviceName: previousEntry?.serviceName || resolveRailwayServiceName(spec, nextManifest.projectSlug),
5279
- };
5280
- }
5281
-
5282
- return nextManifest;
5283
- }
5284
-
5285
- function findRailwayManifestAppServiceByName(appServices, serviceName) {
5286
- return Object.values(appServices || {}).find(
5287
- (entry) => normalizeRailwayServiceName(entry?.serviceName) === normalizeRailwayServiceName(serviceName),
5288
- ) || null;
5289
- }
5290
-
5291
3062
  async function writeProjectConfigFile(projectDir, projectConfig) {
5292
3063
  const configPath = path.join(projectDir, "asaje.config.json");
5293
3064
  await fs.writeJson(configPath, projectConfig, { spaces: 2 });
@@ -5320,20 +3091,6 @@ function printSyncProjectConfigSummary(config) {
5320
3091
  }
5321
3092
  }
5322
3093
 
5323
- function uniqueStrings(values) {
5324
- return [...new Set((values || []).map((value) => String(value || "").trim()).filter(Boolean))];
5325
- }
5326
-
5327
- function resolveProjectSlug(projectDir, projectConfig) {
5328
- return slugify(projectConfig?.projectSlug || projectConfig?.projectName || path.basename(projectDir) || "asaje-app");
5329
- }
5330
-
5331
- function buildRailwayAppServiceName(projectSlug, baseName) {
5332
- const normalizedSlug = slugify(projectSlug || "asaje-app");
5333
- const normalizedBaseName = slugify(baseName);
5334
- return `${normalizedSlug}-${normalizedBaseName}`;
5335
- }
5336
-
5337
3094
  async function updateProjectTemplateConfig(projectDir, projectConfig, templateRepository, templateBranch) {
5338
3095
  const configPath = path.join(projectDir, "asaje.config.json");
5339
3096
  const nextConfig = {
@@ -5414,81 +3171,6 @@ function printUpdateSummary(config) {
5414
3171
  }
5415
3172
  }
5416
3173
 
5417
- function splitCommaSeparatedPaths(value) {
5418
- return value
5419
- .split(",")
5420
- .map((entry) => normalizeRelativePath(entry))
5421
- .filter(Boolean);
5422
- }
5423
-
5424
- function uniquePaths(paths) {
5425
- return [...new Set(paths.map((entry) => normalizeRelativePath(entry)).filter(Boolean))];
5426
- }
5427
-
5428
- function normalizeRelativePath(value) {
5429
- const trimmed = String(value || "").trim();
5430
- if (!trimmed) {
5431
- return "";
5432
- }
5433
-
5434
- return trimmed.replace(/^\.\//, "").replace(/\\/g, "/").replace(/\/$/, "");
5435
- }
5436
-
5437
- async function ensureEnvFiles(projectDir) {
5438
- for (const spec of ENV_FILE_SPECS) {
5439
- const serviceDir = spec.envPath.split("/")[0];
5440
- if (!(await fs.pathExists(path.join(projectDir, serviceDir)))) {
5441
- continue;
5442
- }
5443
-
5444
- const envPath = path.join(projectDir, spec.envPath);
5445
- if (await fs.pathExists(envPath)) {
5446
- continue;
5447
- }
5448
-
5449
- const examplePath = path.join(projectDir, spec.examplePath);
5450
- if (!(await fs.pathExists(examplePath))) {
5451
- throw new Error(`Missing ${spec.envPath} and ${spec.examplePath}`);
5452
- }
5453
-
5454
- await fs.copyFile(examplePath, envPath);
5455
- console.log(pc.yellow(`- Created ${spec.envPath} from ${spec.examplePath}`));
5456
- }
5457
- }
5458
-
5459
- async function loadRuntimeConfig(projectDir) {
5460
- const configPath = path.join(projectDir, "asaje.config.json");
5461
- let ports = { admin: 5173, api: 8080, realtime: 8090, landing: 8088, pwa: 4174 };
5462
-
5463
- if (await fs.pathExists(configPath)) {
5464
- const fileConfig = await fs.readJson(configPath);
5465
- ports = {
5466
- admin: Number(fileConfig?.ports?.admin || ports.admin),
5467
- api: Number(fileConfig?.ports?.api || ports.api),
5468
- landing: Number(fileConfig?.ports?.landing || ports.landing),
5469
- pwa: Number(fileConfig?.ports?.pwa || ports.pwa),
5470
- realtime: Number(fileConfig?.ports?.realtime || ports.realtime),
5471
- };
5472
- } else {
5473
- const [apiEnv, realtimeEnv, adminEnv, landingEnv, pwaEnv] = await Promise.all([
5474
- readEnvFile(path.join(projectDir, "api/.env")),
5475
- readEnvFile(path.join(projectDir, "realtime-gateway/.env")),
5476
- readEnvFile(path.join(projectDir, "admin/.env")),
5477
- tryReadEnvFile(path.join(projectDir, "landing/.env")),
5478
- tryReadEnvFile(path.join(projectDir, "pwa/.env")),
5479
- ]);
5480
- ports = {
5481
- admin: Number(adminEnv.VITE_ADMIN_PORT || ports.admin),
5482
- api: Number(apiEnv.PORT || ports.api),
5483
- landing: Number(landingEnv.PORT || ports.landing),
5484
- pwa: Number(pwaEnv.PORT || pwaEnv.VITE_PORT || ports.pwa),
5485
- realtime: Number(realtimeEnv.PORT || ports.realtime),
5486
- };
5487
- }
5488
-
5489
- return { ports };
5490
- }
5491
-
5492
3174
  function printStartSummary(projectDir, runtimeConfig, profile, selectedServices) {
5493
3175
  console.log(pc.green("\nStarting project services."));
5494
3176
  console.log(`- Directory: ${pc.bold(projectDir)}`);
@@ -5604,249 +3286,3 @@ async function startManagedProcesses(projectDir, runtimeConfig, selectedServices
5604
3286
  process.removeListener("SIGTERM", onSignal);
5605
3287
  }
5606
3288
  }
5607
-
5608
- function createManagedProcess(spec) {
5609
- const child = execa(spec.command, spec.args, {
5610
- cwd: spec.cwd,
5611
- stderr: "pipe",
5612
- stdout: "pipe",
5613
- });
5614
-
5615
- const prefix = spec.color(`[${spec.name}] `);
5616
- child.stdout?.on("data", (chunk) => {
5617
- process.stdout.write(prefixChunk(prefix, chunk));
5618
- });
5619
- child.stderr?.on("data", (chunk) => {
5620
- process.stderr.write(prefixChunk(prefix, chunk));
5621
- });
5622
-
5623
- const managed = child.then((result) => ({ ...result, name: spec.name }));
5624
- managed.kill = (...args) => child.kill(...args);
5625
- return managed;
5626
- }
5627
-
5628
- function prefixChunk(prefix, chunk) {
5629
- const textValue = chunk.toString();
5630
- const normalized = textValue.replace(/\n/g, `\n${prefix}`);
5631
- return `${prefix}${normalized}`;
5632
- }
5633
-
5634
- async function cloneTemplate(template, branch, destinationDir) {
5635
- const fallbackBranches = [branch, "main", "master", "develop"].filter(
5636
- (value, index, array) => value && array.indexOf(value) === index,
5637
- );
5638
- let lastError = null;
5639
-
5640
- for (const candidate of fallbackBranches) {
5641
- try {
5642
- const emitter = degit(`${template}#${candidate}`, {
5643
- cache: false,
5644
- force: false,
5645
- verbose: false,
5646
- });
5647
- await emitter.clone(destinationDir);
5648
- return;
5649
- } catch (error) {
5650
- lastError = error;
5651
- }
5652
- }
5653
-
5654
- throw lastError instanceof Error ? lastError : new Error(`Unable to clone template ${template}`);
5655
- }
5656
-
5657
- async function cleanupTemplateFiles(destinationDir) {
5658
- for (const relativePath of EXCLUDED_TEMPLATE_PATHS) {
5659
- await fs.remove(path.join(destinationDir, relativePath));
5660
- }
5661
- }
5662
-
5663
- async function ensureDestinationIsAvailable(destinationDir) {
5664
- const exists = await fs.pathExists(destinationDir);
5665
- if (!exists) {
5666
- return;
5667
- }
5668
-
5669
- const files = await fs.readdir(destinationDir);
5670
- if (files.length > 0) {
5671
- throw new Error(`Destination already exists and is not empty: ${destinationDir}. Choose another directory or empty it before running create.`);
5672
- }
5673
- }
5674
-
5675
- async function installProjectDependencies(projectDir) {
5676
- await runCommand("pnpm", ["install"], path.join(projectDir, "admin"));
5677
- if (await fs.pathExists(path.join(projectDir, "landing/package.json"))) {
5678
- await runCommand("pnpm", ["install"], path.join(projectDir, "landing"));
5679
- }
5680
- if (await fs.pathExists(path.join(projectDir, "pwa/package.json"))) {
5681
- await runCommand("pnpm", ["install"], path.join(projectDir, "pwa"));
5682
- }
5683
- await runCommand("go", ["mod", "download"], path.join(projectDir, "api"));
5684
- await runCommand("go", ["mod", "download"], path.join(projectDir, "realtime-gateway"));
5685
- }
5686
-
5687
- async function startInfrastructure(projectDir) {
5688
- await runCommand(
5689
- "docker",
5690
- ["compose", "up", "-d", "postgres", "rabbitmq", "minio", "minio-create-bucket"],
5691
- projectDir,
5692
- );
5693
- }
5694
-
5695
- async function runCommand(command, args, cwd) {
5696
- await execa(command, args, {
5697
- cwd,
5698
- stdio: "inherit",
5699
- });
5700
- }
5701
-
5702
- async function checkCommand(spec) {
5703
- try {
5704
- const result = await execa(spec.command, spec.args, {
5705
- reject: false,
5706
- });
5707
-
5708
- if (result.exitCode !== 0) {
5709
- return { message: `${spec.name} unavailable`, ok: false };
5710
- }
5711
-
5712
- const version = (result.stdout || result.stderr || "").split(/\r?\n/)[0].trim();
5713
- return { message: `${spec.name} detected${version ? ` (${version})` : ""}`, ok: true };
5714
- } catch {
5715
- return { message: `${spec.name} unavailable`, ok: false };
5716
- }
5717
- }
5718
-
5719
- async function readEnvFile(filePath) {
5720
- const contents = await fs.readFile(filePath, "utf8");
5721
- const result = {};
5722
-
5723
- for (const line of contents.split(/\r?\n/)) {
5724
- const trimmed = line.trim();
5725
- if (!trimmed || trimmed.startsWith("#")) {
5726
- continue;
5727
- }
5728
-
5729
- const separatorIndex = trimmed.indexOf("=");
5730
- if (separatorIndex === -1) {
5731
- continue;
5732
- }
5733
-
5734
- const key = trimmed.slice(0, separatorIndex).trim();
5735
- let value = trimmed.slice(separatorIndex + 1).trim();
5736
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
5737
- value = value.slice(1, -1);
5738
- }
5739
- result[key] = value;
5740
- }
5741
-
5742
- return result;
5743
- }
5744
-
5745
- function toEnvContent(values) {
5746
- return `${Object.entries(values)
5747
- .map(([key, value]) => `${key}=${escapeEnvValue(String(value))}`)
5748
- .join("\n")}\n`;
5749
- }
5750
-
5751
- function escapeEnvValue(value) {
5752
- if (value === "") {
5753
- return "";
5754
- }
5755
-
5756
- if (/\s|#|"/.test(value)) {
5757
- return JSON.stringify(value);
5758
- }
5759
-
5760
- return value;
5761
- }
5762
-
5763
- async function prompt(promise) {
5764
- const result = await promise;
5765
- if (isCancel(result)) {
5766
- cancel("Operation cancelled");
5767
- process.exit(0);
5768
- }
5769
- return result;
5770
- }
5771
-
5772
- async function promptNumber(message, defaultValue) {
5773
- const result = await prompt(
5774
- text({
5775
- defaultValue: String(defaultValue),
5776
- message,
5777
- validate(value) {
5778
- const number = Number(value);
5779
- return Number.isInteger(number) && number > 0 ? undefined : "Enter a positive integer";
5780
- },
5781
- }),
5782
- );
5783
-
5784
- return Number(result);
5785
- }
5786
-
5787
- async function promptEmail(message, defaultValue) {
5788
- return prompt(
5789
- text({
5790
- defaultValue,
5791
- message,
5792
- validate(value) {
5793
- return value.includes("@") ? undefined : "Enter a valid email";
5794
- },
5795
- }),
5796
- );
5797
- }
5798
-
5799
- async function promptSecret(message, minLength, defaultValue) {
5800
- return prompt(
5801
- password({
5802
- message,
5803
- ...(defaultValue ? { mask: "*" } : {}),
5804
- validate(value) {
5805
- return value.length >= minLength ? undefined : `Must be at least ${minLength} characters`;
5806
- },
5807
- }),
5808
- );
5809
- }
5810
-
5811
- function splitCsv(value) {
5812
- return value
5813
- .split(",")
5814
- .map((item) => item.trim())
5815
- .filter(Boolean);
5816
- }
5817
-
5818
- function slugify(value) {
5819
- return value
5820
- .toLowerCase()
5821
- .replace(/[^a-z0-9]+/g, "-")
5822
- .replace(/^-+|-+$/g, "");
5823
- }
5824
-
5825
- function titleize(value) {
5826
- return value
5827
- .split(/[-_\s]+/)
5828
- .filter(Boolean)
5829
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
5830
- .join(" ");
5831
- }
5832
-
5833
- function randomSecret(bytes) {
5834
- return crypto.randomBytes(bytes).toString("base64url");
5835
- }
5836
-
5837
- function shellEscape(value) {
5838
- if (/^[a-zA-Z0-9_./-]+$/.test(value)) {
5839
- return value;
5840
- }
5841
-
5842
- return JSON.stringify(value);
5843
- }
5844
-
5845
- function isHttpUrl(value) {
5846
- try {
5847
- const parsed = new URL(value);
5848
- return parsed.protocol === "http:" || parsed.protocol === "https:";
5849
- } catch {
5850
- return false;
5851
- }
5852
- }