create-asaje-go-vue 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,6 +44,20 @@ npx -p create-asaje-go-vue@latest asaje doctor ./my-app
44
44
  npx -p create-asaje-go-vue@latest asaje publish
45
45
  ```
46
46
 
47
+ ### Provision Railway resources
48
+
49
+ ```bash
50
+ npx -p create-asaje-go-vue@latest asaje setup-railway ./my-app
51
+ npx -p create-asaje-go-vue@latest asaje setup-railway ./my-app --dry-run
52
+ ```
53
+
54
+ ### Sync Railway app variables
55
+
56
+ ```bash
57
+ npx -p create-asaje-go-vue@latest asaje sync-railway-env ./my-app
58
+ npx -p create-asaje-go-vue@latest asaje sync-railway-env ./my-app --dry-run
59
+ ```
60
+
47
61
  ## What `create` does
48
62
 
49
63
  - clones the boilerplate from GitHub with `degit`
@@ -75,6 +89,28 @@ npx -p create-asaje-go-vue@latest asaje publish
75
89
  - runs `npm run pack:dry-run`
76
90
  - prints the final manual npm release steps
77
91
 
92
+ ## What `asaje setup-railway` does
93
+
94
+ - validates the target project structure
95
+ - checks that the Railway CLI is installed and authenticated
96
+ - reads the linked Railway project context
97
+ - provisions PostgreSQL, RabbitMQ, and S3-compatible object storage on Railway
98
+ - creates missing Railway app services for `api`, `realtime-gateway`, and `admin`
99
+ - wires Railway variables for `api`, `realtime-gateway`, and `admin`
100
+ - triggers the first Railway deployment for each app service using the service-local `Dockerfile` and `railway.json`
101
+ - generates missing app secrets such as `JWT_SECRET` and `SWAGGER_PASSWORD`, while reusing existing Railway values when present
102
+ - supports `--dry-run` to preview provisioning and variable changes without applying them
103
+ - writes an `asaje.railway.json` manifest in the target project for future runs, including discovered Railway app service names
104
+
105
+ ## What `asaje sync-railway-env` does
106
+
107
+ - validates the target project structure
108
+ - checks that the Railway CLI is installed and authenticated
109
+ - reads the linked Railway project context
110
+ - discovers existing Railway app and infra services
111
+ - syncs variables for `api`, `realtime-gateway`, and `admin` without provisioning infra resources
112
+ - supports `--dry-run` to preview variable changes without applying them
113
+
78
114
  ## Useful flags
79
115
 
80
116
  ```bash
@@ -85,6 +121,9 @@ node ./bin/asaje.js start ../my-app --yes --profile frontend-only
85
121
  node ./bin/asaje.js start ../my-app --yes --skip-admin --skip-worker
86
122
  node ./bin/asaje.js doctor ../my-app
87
123
  node ./bin/asaje.js publish .
124
+ node ./bin/asaje.js setup-railway ../my-app --yes
125
+ node ./bin/asaje.js setup-railway ../my-app --yes --dry-run
126
+ node ./bin/asaje.js sync-railway-env ../my-app --yes
88
127
  ```
89
128
 
90
129
  ## Publish checklist
@@ -103,5 +142,6 @@ npm publish
103
142
  - default template repo comes from `ASAJE_TEMPLATE_REPO` or falls back to `asaje379/boilerplate-go-vue`
104
143
  - default branch comes from `ASAJE_TEMPLATE_BRANCH` or falls back to `main`
105
144
  - if the selected branch is missing, the CLI retries `main`, `master`, then `develop`
145
+ - `asaje setup-railway` works best with `RAILWAY_API_TOKEN` or `RAILWAY_TOKEN` set so it can verify existing remote services before provisioning
106
146
  - the package currently uses `UNLICENSED`; change that before public distribution if needed
107
147
  - OTP email delivery still requires a valid Mailchimp Transactional key for real email sends
@@ -21,6 +21,31 @@ import pc from "picocolors";
21
21
  const DEFAULT_TEMPLATE = process.env.ASAJE_TEMPLATE_REPO || "asaje379/boilerplate-go-vue";
22
22
  const DEFAULT_BRANCH = process.env.ASAJE_TEMPLATE_BRANCH || "main";
23
23
  const EXCLUDED_TEMPLATE_PATHS = ["cli"];
24
+ const RAILWAY_GRAPHQL_ENDPOINT = "https://backboard.railway.com/graphql/v2";
25
+ const RAILWAY_MANIFEST_FILENAME = "asaje.railway.json";
26
+ const DEFAULT_RAILWAY_BUCKET = "boilerplate-files";
27
+ const RAILWAY_SERVICE_DISCOVERY_RETRY_DELAY_MS = 2000;
28
+ const RAILWAY_SERVICE_DISCOVERY_RETRY_COUNT = 5;
29
+ const RAILWAY_APP_SERVICE_SPECS = [
30
+ {
31
+ aliases: ["api", "backend", "server"],
32
+ directory: "api",
33
+ key: "api",
34
+ serviceName: "api",
35
+ },
36
+ {
37
+ aliases: ["admin", "frontend", "web"],
38
+ directory: "admin",
39
+ key: "admin",
40
+ serviceName: "admin",
41
+ },
42
+ {
43
+ aliases: ["realtime-gateway", "realtime"],
44
+ directory: "realtime-gateway",
45
+ key: "realtime",
46
+ serviceName: "realtime-gateway",
47
+ },
48
+ ];
24
49
  const ENV_FILE_SPECS = [
25
50
  { envPath: "admin/.env", examplePath: "admin/.env.example" },
26
51
  { envPath: "api/.env", examplePath: "api/.env.example" },
@@ -58,6 +83,18 @@ async function main() {
58
83
  return;
59
84
  }
60
85
 
86
+ if (invocation.command === "setup-railway") {
87
+ await runSetupRailway(invocation.argv);
88
+ outro(pc.green("Railway setup complete."));
89
+ return;
90
+ }
91
+
92
+ if (invocation.command === "sync-railway-env") {
93
+ await runSyncRailwayEnv(invocation.argv);
94
+ outro(pc.green("Railway environment sync complete."));
95
+ return;
96
+ }
97
+
61
98
  await runCreate(invocation.argv);
62
99
  outro(pc.green("Project ready."));
63
100
  } catch (error) {
@@ -98,6 +135,14 @@ function resolveInvocation(argv) {
98
135
  return { argv: rawArgs.slice(1), command: "publish", title: "asaje publish" };
99
136
  }
100
137
 
138
+ if (firstArg === "setup-railway") {
139
+ return { argv: rawArgs.slice(1), command: "setup-railway", title: "asaje setup-railway" };
140
+ }
141
+
142
+ if (firstArg === "sync-railway-env") {
143
+ return { argv: rawArgs.slice(1), command: "sync-railway-env", title: "asaje sync-railway-env" };
144
+ }
145
+
101
146
  if (firstArg === "create") {
102
147
  return { argv: rawArgs.slice(1), command: "create", title: "asaje create" };
103
148
  }
@@ -127,12 +172,16 @@ function printHelp() {
127
172
  console.log(`- ${pc.bold("asaje start [directory]")} start a configured project`);
128
173
  console.log(`- ${pc.bold("asaje doctor [directory]")} check environment and project readiness`);
129
174
  console.log(`- ${pc.bold("asaje publish")} run npm publish checklist for the CLI package`);
175
+ console.log(`- ${pc.bold("asaje setup-railway [directory]")} provision Railway infrastructure for a project`);
176
+ console.log(`- ${pc.bold("asaje sync-railway-env [directory]")} sync Railway app variables without provisioning`);
130
177
  console.log(pc.bold("\nExamples"));
131
178
  console.log(`- ${pc.bold("npx create-asaje-go-vue my-app")}`);
132
179
  console.log(`- ${pc.bold("node ./bin/create-asaje-go-vue.js my-app --yes")}`);
133
180
  console.log(`- ${pc.bold("node ./bin/asaje.js start ../my-app")}`);
134
181
  console.log(`- ${pc.bold("node ./bin/asaje.js doctor ..")}`);
135
182
  console.log(`- ${pc.bold("node ./bin/asaje.js publish")}`);
183
+ console.log(`- ${pc.bold("node ./bin/asaje.js setup-railway ..")}`);
184
+ console.log(`- ${pc.bold("node ./bin/asaje.js sync-railway-env ..")}`);
136
185
  }
137
186
 
138
187
  async function runCreate(argv) {
@@ -862,10 +911,1219 @@ async function runPublish(argv) {
862
911
  console.log(`- Publish with ${pc.bold("npm publish")}`);
863
912
  }
864
913
 
914
+ async function runSetupRailway(argv) {
915
+ const args = parseSetupRailwayArgs(argv);
916
+ const answers = await collectSetupRailwayAnswers(args);
917
+ const projectDir = path.resolve(process.cwd(), answers.directory);
918
+
919
+ await ensureProjectStructure(projectDir);
920
+ await ensureRailwayCliInstalled();
921
+ await ensureRailwayAuthenticated(projectDir, answers.environment);
922
+
923
+ const manifest = await readRailwayManifest(projectDir);
924
+ manifest.resources ||= {};
925
+ const railwayContext = await loadRailwayContext(projectDir, answers.environment);
926
+ railwayContext.environmentRef = answers.environment || railwayContext.environmentId || railwayContext.environmentName;
927
+ const existingServices = await discoverRailwayServices(railwayContext, projectDir);
928
+ const resourceSummary = [];
929
+ const appServiceSummary = [];
930
+ const deploySummary = [];
931
+ const variableSummary = [];
932
+
933
+ console.log(pc.bold("\nProvisioning"));
934
+
935
+ const postgresResult = await ensureRailwayResource({
936
+ aliases: ["postgres", "postgresql"],
937
+ commandArgs: ["add", "--database", "postgres"],
938
+ dryRun: answers.dryRun,
939
+ existingServices,
940
+ key: "postgres",
941
+ manifest,
942
+ projectDir,
943
+ railwayContext,
944
+ });
945
+ resourceSummary.push(postgresResult);
946
+
947
+ const rabbitMqResult = await ensureRailwayResource({
948
+ aliases: ["rabbitmq"],
949
+ commandArgs: ["deploy", "--template", "RabbitMQ"],
950
+ dryRun: answers.dryRun,
951
+ existingServices,
952
+ key: "rabbitmq",
953
+ manifest,
954
+ projectDir,
955
+ railwayContext,
956
+ });
957
+ resourceSummary.push(rabbitMqResult);
958
+
959
+ const objectStorageResult = await ensureRailwayResource({
960
+ aliases: ["object-storage", "storage", "simple-s3", "minio"],
961
+ commandArgs: [
962
+ "deploy",
963
+ "--template",
964
+ "simple-s3",
965
+ "--variable",
966
+ `MINIO_BUCKET=${answers.bucket}`,
967
+ ],
968
+ dryRun: answers.dryRun,
969
+ existingServices,
970
+ key: "objectStorage",
971
+ manifest,
972
+ metadata: { bucket: answers.bucket },
973
+ projectDir,
974
+ railwayContext,
975
+ });
976
+ resourceSummary.push(objectStorageResult);
977
+
978
+ console.log(pc.bold("\nApplication services"));
979
+ manifest.appServices ||= {};
980
+ for (const spec of RAILWAY_APP_SERVICE_SPECS) {
981
+ const serviceResult = await ensureRailwayAppService({
982
+ aliases: spec.aliases,
983
+ dryRun: answers.dryRun,
984
+ existingServices,
985
+ key: spec.key,
986
+ manifest,
987
+ projectDir,
988
+ railwayContext,
989
+ serviceName: spec.serviceName,
990
+ });
991
+ appServiceSummary.push(serviceResult);
992
+ }
993
+
994
+ manifest.bucket = answers.bucket;
995
+ manifest.environmentId = railwayContext.environmentId || manifest.environmentId || null;
996
+ manifest.environmentName = railwayContext.environmentName || manifest.environmentName || null;
997
+ manifest.projectId = railwayContext.projectId || manifest.projectId || null;
998
+ manifest.projectName = railwayContext.projectName || manifest.projectName || null;
999
+ manifest.updatedAt = new Date().toISOString();
1000
+
1001
+ const servicesAfterProvision = await discoverRailwayServices(railwayContext, projectDir);
1002
+ updateRailwayManifestAppServices(manifest, servicesAfterProvision);
1003
+ await wireRailwayVariables({
1004
+ dryRun: answers.dryRun,
1005
+ manifest,
1006
+ projectDir,
1007
+ railwayContext,
1008
+ services: servicesAfterProvision,
1009
+ summary: variableSummary,
1010
+ });
1011
+ const deploymentResults = await deployRailwayAppServices({
1012
+ dryRun: answers.dryRun,
1013
+ manifest,
1014
+ projectDir,
1015
+ railwayContext,
1016
+ services: servicesAfterProvision,
1017
+ });
1018
+ deploySummary.push(...deploymentResults);
1019
+
1020
+ if (!answers.dryRun) {
1021
+ await writeRailwayManifest(projectDir, manifest);
1022
+ }
1023
+ printRailwaySetupSummary({
1024
+ appServiceSummary,
1025
+ bucket: answers.bucket,
1026
+ deploySummary,
1027
+ dryRun: answers.dryRun,
1028
+ projectDir,
1029
+ railwayContext,
1030
+ resourceSummary,
1031
+ variableSummary,
1032
+ });
1033
+ }
1034
+
1035
+ async function runSyncRailwayEnv(argv) {
1036
+ const args = parseSetupRailwayArgs(argv);
1037
+ const answers = await collectSetupRailwayAnswers(args);
1038
+ const projectDir = path.resolve(process.cwd(), answers.directory);
1039
+
1040
+ await ensureProjectStructure(projectDir);
1041
+ await ensureRailwayCliInstalled();
1042
+ await ensureRailwayAuthenticated(projectDir, answers.environment);
1043
+
1044
+ const manifest = await readRailwayManifest(projectDir);
1045
+ manifest.resources ||= {};
1046
+ manifest.appServices ||= {};
1047
+
1048
+ const railwayContext = await loadRailwayContext(projectDir, answers.environment);
1049
+ railwayContext.environmentRef = answers.environment || railwayContext.environmentId || railwayContext.environmentName;
1050
+ const services = await discoverRailwayServices(railwayContext, projectDir);
1051
+ const variableSummary = [];
1052
+
1053
+ updateRailwayManifestAppServices(manifest, services);
1054
+ await wireRailwayVariables({
1055
+ dryRun: answers.dryRun,
1056
+ manifest,
1057
+ projectDir,
1058
+ railwayContext,
1059
+ services,
1060
+ summary: variableSummary,
1061
+ });
1062
+
1063
+ manifest.bucket = manifest.bucket || answers.bucket;
1064
+ manifest.environmentId = railwayContext.environmentId || manifest.environmentId || null;
1065
+ manifest.environmentName = railwayContext.environmentName || manifest.environmentName || null;
1066
+ manifest.projectId = railwayContext.projectId || manifest.projectId || null;
1067
+ manifest.projectName = railwayContext.projectName || manifest.projectName || null;
1068
+ manifest.updatedAt = new Date().toISOString();
1069
+
1070
+ if (!answers.dryRun) {
1071
+ await writeRailwayManifest(projectDir, manifest);
1072
+ }
1073
+
1074
+ printRailwaySetupSummary({
1075
+ appServiceSummary: [],
1076
+ bucket: manifest.bucket || answers.bucket,
1077
+ deploySummary: [],
1078
+ dryRun: answers.dryRun,
1079
+ projectDir,
1080
+ railwayContext,
1081
+ resourceSummary: [],
1082
+ variableSummary,
1083
+ });
1084
+ }
1085
+
865
1086
  function parseDirectoryArgs(argv) {
866
1087
  return { directory: argv[0] || "." };
867
1088
  }
868
1089
 
1090
+ function parseSetupRailwayArgs(argv) {
1091
+ const options = {
1092
+ bucket: DEFAULT_RAILWAY_BUCKET,
1093
+ directory: ".",
1094
+ dryRun: false,
1095
+ environment: undefined,
1096
+ yes: false,
1097
+ };
1098
+ const positionals = [];
1099
+
1100
+ for (let index = 0; index < argv.length; index += 1) {
1101
+ const arg = argv[index];
1102
+
1103
+ if (arg === "--yes" || arg === "-y") {
1104
+ options.yes = true;
1105
+ continue;
1106
+ }
1107
+
1108
+ if (arg === "--dry-run") {
1109
+ options.dryRun = true;
1110
+ continue;
1111
+ }
1112
+
1113
+ if (arg === "--bucket") {
1114
+ options.bucket = argv[index + 1] || options.bucket;
1115
+ index += 1;
1116
+ continue;
1117
+ }
1118
+
1119
+ if (arg.startsWith("--bucket=")) {
1120
+ options.bucket = arg.split("=")[1] || options.bucket;
1121
+ continue;
1122
+ }
1123
+
1124
+ if (arg === "--environment" || arg === "-e") {
1125
+ options.environment = argv[index + 1] || options.environment;
1126
+ index += 1;
1127
+ continue;
1128
+ }
1129
+
1130
+ if (arg.startsWith("--environment=")) {
1131
+ options.environment = arg.split("=")[1] || options.environment;
1132
+ continue;
1133
+ }
1134
+
1135
+ positionals.push(arg);
1136
+ }
1137
+
1138
+ options.directory = positionals[0] || options.directory;
1139
+ return options;
1140
+ }
1141
+
1142
+ async function collectSetupRailwayAnswers(args) {
1143
+ if (args.yes) {
1144
+ return {
1145
+ bucket: args.bucket,
1146
+ directory: args.directory,
1147
+ dryRun: args.dryRun,
1148
+ environment: args.environment,
1149
+ };
1150
+ }
1151
+
1152
+ const directory = await prompt(
1153
+ text({
1154
+ defaultValue: args.directory,
1155
+ message: "Project directory to configure on Railway?",
1156
+ placeholder: ".",
1157
+ validate(value) {
1158
+ return value.trim().length === 0 ? "Project directory is required" : undefined;
1159
+ },
1160
+ }),
1161
+ );
1162
+
1163
+ const bucket = await prompt(
1164
+ text({
1165
+ defaultValue: args.bucket,
1166
+ message: "Object storage bucket name?",
1167
+ placeholder: DEFAULT_RAILWAY_BUCKET,
1168
+ validate(value) {
1169
+ return /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value)
1170
+ ? undefined
1171
+ : "Use 3-63 lowercase letters, numbers, dots, or hyphens";
1172
+ },
1173
+ }),
1174
+ );
1175
+
1176
+ let environment = args.environment;
1177
+ if (!environment) {
1178
+ environment = await prompt(
1179
+ text({
1180
+ defaultValue: "",
1181
+ message: "Railway environment name or ID? (leave empty for linked default)",
1182
+ placeholder: "production",
1183
+ }),
1184
+ );
1185
+ }
1186
+
1187
+ return {
1188
+ bucket,
1189
+ directory,
1190
+ dryRun: args.dryRun,
1191
+ environment: environment?.trim() || undefined,
1192
+ };
1193
+ }
1194
+
1195
+ async function ensureRailwayCliInstalled() {
1196
+ const result = await checkCommand({ command: "railway", args: ["--version"], name: "railway" });
1197
+ if (!result.ok) {
1198
+ throw new Error(
1199
+ "Railway CLI is required for this command. Install it with `brew install railway` or `npm i -g @railway/cli`.",
1200
+ );
1201
+ }
1202
+ }
1203
+
1204
+ async function ensureRailwayAuthenticated(projectDir, environment) {
1205
+ const result = await execa("railway", buildRailwayArgs(["whoami"], environment), {
1206
+ cwd: projectDir,
1207
+ reject: false,
1208
+ });
1209
+
1210
+ if (result.exitCode !== 0) {
1211
+ throw new Error("Railway CLI is not authenticated. Run `railway login` and try again.");
1212
+ }
1213
+ }
1214
+
1215
+ async function readRailwayManifest(projectDir) {
1216
+ const manifestPath = path.join(projectDir, RAILWAY_MANIFEST_FILENAME);
1217
+ if (!(await fs.pathExists(manifestPath))) {
1218
+ return {
1219
+ appServices: {},
1220
+ bucket: DEFAULT_RAILWAY_BUCKET,
1221
+ environmentId: null,
1222
+ environmentName: null,
1223
+ projectId: null,
1224
+ projectName: null,
1225
+ resources: {},
1226
+ updatedAt: null,
1227
+ };
1228
+ }
1229
+
1230
+ return fs.readJson(manifestPath);
1231
+ }
1232
+
1233
+ async function writeRailwayManifest(projectDir, manifest) {
1234
+ const manifestPath = path.join(projectDir, RAILWAY_MANIFEST_FILENAME);
1235
+ await fs.writeJson(manifestPath, manifest, { spaces: 2 });
1236
+ }
1237
+
1238
+ async function loadRailwayContext(projectDir, environment) {
1239
+ const result = await execa("railway", buildRailwayArgs(["status", "--json"], environment), {
1240
+ cwd: projectDir,
1241
+ reject: false,
1242
+ });
1243
+
1244
+ if (result.exitCode !== 0) {
1245
+ throw new Error(
1246
+ `Unable to read Railway project status for ${projectDir}. Link the directory first with \`railway link\` or \`railway init\`.`,
1247
+ );
1248
+ }
1249
+
1250
+ const payload = parseJsonOutput(result.stdout);
1251
+ if (!payload) {
1252
+ throw new Error("Railway status returned an unexpected response. Make sure the project is linked and try again.");
1253
+ }
1254
+
1255
+ const project = payload.project || payload.linkedProject || payload.workspace?.project || null;
1256
+ const environmentData = payload.environment || payload.linkedEnvironment || payload.deployment?.environment || null;
1257
+
1258
+ return {
1259
+ environmentId: pickFirstString([
1260
+ environmentData?.id,
1261
+ payload.environmentId,
1262
+ findFirstNestedValue(payload, "environmentId"),
1263
+ ]),
1264
+ environmentName: pickFirstString([
1265
+ environmentData?.name,
1266
+ payload.environmentName,
1267
+ environment,
1268
+ findFirstNestedValue(payload, "environmentName"),
1269
+ ]),
1270
+ projectId: pickFirstString([project?.id, payload.projectId, findFirstNestedValue(payload, "projectId")]),
1271
+ projectName: pickFirstString([
1272
+ project?.name,
1273
+ payload.projectName,
1274
+ findFirstNestedValue(payload, "projectName"),
1275
+ ]),
1276
+ };
1277
+ }
1278
+
1279
+ async function discoverRailwayServices(railwayContext, projectDir) {
1280
+ const cliServices = await discoverRailwayServicesViaCli(railwayContext, projectDir);
1281
+ if (cliServices.length > 0) {
1282
+ return cliServices;
1283
+ }
1284
+
1285
+ const auth = getRailwayApiAuth();
1286
+ if (!auth || !railwayContext.projectId) {
1287
+ return [];
1288
+ }
1289
+
1290
+ try {
1291
+ const response = await fetch(RAILWAY_GRAPHQL_ENDPOINT, {
1292
+ body: JSON.stringify({
1293
+ query: `query SetupRailwayServices($projectId: String!) {
1294
+ project(id: $projectId) {
1295
+ services {
1296
+ edges {
1297
+ node {
1298
+ id
1299
+ name
1300
+ icon
1301
+ }
1302
+ }
1303
+ }
1304
+ }
1305
+ }`,
1306
+ variables: {
1307
+ projectId: railwayContext.projectId,
1308
+ },
1309
+ }),
1310
+ headers: {
1311
+ ...auth.headers,
1312
+ "Content-Type": "application/json",
1313
+ },
1314
+ method: "POST",
1315
+ });
1316
+
1317
+ if (!response.ok) {
1318
+ return [];
1319
+ }
1320
+
1321
+ const payload = await response.json();
1322
+ const nodes = payload?.data?.project?.services?.edges || [];
1323
+ return nodes
1324
+ .map((edge) => edge?.node)
1325
+ .filter(Boolean)
1326
+ .map((service) => ({
1327
+ icon: typeof service.icon === "string" ? service.icon : null,
1328
+ id: typeof service.id === "string" ? service.id : null,
1329
+ name: typeof service.name === "string" ? service.name : null,
1330
+ }));
1331
+ } catch {
1332
+ return [];
1333
+ }
1334
+ }
1335
+
1336
+ async function discoverRailwayServicesViaCli(railwayContext, projectDir) {
1337
+ try {
1338
+ const result = await execa(
1339
+ "railway",
1340
+ buildRailwayArgs(["service", "status", "--all", "--json"], railwayContext.environmentRef),
1341
+ {
1342
+ cwd: projectDir,
1343
+ reject: false,
1344
+ },
1345
+ );
1346
+
1347
+ if (result.exitCode !== 0) {
1348
+ return [];
1349
+ }
1350
+
1351
+ const payload = parseJsonOutput(result.stdout);
1352
+ if (!payload) {
1353
+ return [];
1354
+ }
1355
+
1356
+ return normalizeRailwayServices(extractRailwayServiceCandidates(payload));
1357
+ } catch {
1358
+ return [];
1359
+ }
1360
+ }
1361
+
1362
+ function getRailwayApiAuth() {
1363
+ if (process.env.RAILWAY_API_TOKEN) {
1364
+ return {
1365
+ headers: {
1366
+ Authorization: `Bearer ${process.env.RAILWAY_API_TOKEN}`,
1367
+ },
1368
+ };
1369
+ }
1370
+
1371
+ if (process.env.RAILWAY_TOKEN) {
1372
+ return {
1373
+ headers: {
1374
+ "Project-Access-Token": process.env.RAILWAY_TOKEN,
1375
+ },
1376
+ };
1377
+ }
1378
+
1379
+ return null;
1380
+ }
1381
+
1382
+ async function ensureRailwayResource(config) {
1383
+ const manifestEntry = config.manifest.resources?.[config.key];
1384
+ const existingService = findRailwayService(config.existingServices, config.aliases, manifestEntry?.serviceName);
1385
+
1386
+ if (existingService) {
1387
+ config.manifest.resources[config.key] = {
1388
+ bucket: config.metadata?.bucket || manifestEntry?.bucket || null,
1389
+ detectedAt: new Date().toISOString(),
1390
+ serviceId: existingService.id || manifestEntry?.serviceId || null,
1391
+ serviceName: existingService.name || manifestEntry?.serviceName || null,
1392
+ source: "remote",
1393
+ status: "existing",
1394
+ };
1395
+
1396
+ console.log(`- ${pc.cyan(config.key)} already present${existingService.name ? ` (${existingService.name})` : ""}`);
1397
+ return {
1398
+ key: config.key,
1399
+ serviceName: existingService.name || manifestEntry?.serviceName || null,
1400
+ status: "existing",
1401
+ };
1402
+ }
1403
+
1404
+ if (manifestEntry?.status === "created" || manifestEntry?.status === "existing") {
1405
+ console.log(`- ${pc.yellow(config.key)} tracked in ${RAILWAY_MANIFEST_FILENAME} but not found remotely, recreating...`);
1406
+ }
1407
+
1408
+ console.log(`- creating ${pc.cyan(config.key)}...`);
1409
+ const servicesBefore = normalizeRailwayServices(config.existingServices);
1410
+ if (!config.dryRun) {
1411
+ await runRailwayCommand(config.projectDir, config.railwayContext.environmentRef, config.commandArgs);
1412
+ }
1413
+
1414
+ let createdService = null;
1415
+ if (!config.dryRun) {
1416
+ createdService = await waitForCreatedRailwayService({
1417
+ aliases: config.aliases,
1418
+ beforeServices: servicesBefore,
1419
+ key: config.key,
1420
+ manifestEntry,
1421
+ projectDir: config.projectDir,
1422
+ railwayContext: config.railwayContext,
1423
+ });
1424
+ }
1425
+
1426
+ config.manifest.resources[config.key] = {
1427
+ bucket: config.metadata?.bucket || null,
1428
+ detectedAt: new Date().toISOString(),
1429
+ serviceId: createdService?.id || null,
1430
+ serviceName: createdService?.name || null,
1431
+ source: "cli",
1432
+ status: "created",
1433
+ };
1434
+
1435
+ return {
1436
+ key: config.key,
1437
+ serviceName: createdService?.name || null,
1438
+ status: config.dryRun ? "dry-run" : "created",
1439
+ };
1440
+ }
1441
+
1442
+ async function ensureRailwayAppService(config) {
1443
+ const manifestEntry = config.manifest.appServices?.[config.key];
1444
+ const existingService = findRailwayService(
1445
+ config.existingServices,
1446
+ config.aliases,
1447
+ manifestEntry?.serviceName || config.serviceName,
1448
+ );
1449
+
1450
+ if (existingService) {
1451
+ config.manifest.appServices[config.key] = {
1452
+ serviceId: existingService.id || manifestEntry?.serviceId || null,
1453
+ serviceName: existingService.name || manifestEntry?.serviceName || config.serviceName,
1454
+ };
1455
+
1456
+ console.log(`- ${pc.cyan(config.serviceName)} already present${existingService.name ? ` (${existingService.name})` : ""}`);
1457
+ return {
1458
+ key: config.serviceName,
1459
+ serviceName: existingService.name || config.serviceName,
1460
+ status: "existing",
1461
+ };
1462
+ }
1463
+
1464
+ if (manifestEntry?.serviceName) {
1465
+ console.log(`- ${pc.yellow(config.serviceName)} tracked in ${RAILWAY_MANIFEST_FILENAME} but not found remotely, recreating...`);
1466
+ }
1467
+
1468
+ console.log(`- creating ${pc.cyan(config.serviceName)} service...`);
1469
+ const servicesBefore = normalizeRailwayServices(config.existingServices);
1470
+ if (!config.dryRun) {
1471
+ await runRailwayCommand(config.projectDir, config.railwayContext.environmentRef, ["add", "--service", config.serviceName]);
1472
+ }
1473
+
1474
+ let createdService = null;
1475
+ if (!config.dryRun) {
1476
+ createdService = await waitForCreatedRailwayService({
1477
+ aliases: config.aliases,
1478
+ beforeServices: servicesBefore,
1479
+ key: config.serviceName,
1480
+ manifestEntry,
1481
+ projectDir: config.projectDir,
1482
+ railwayContext: config.railwayContext,
1483
+ });
1484
+ }
1485
+
1486
+ config.manifest.appServices[config.key] = {
1487
+ serviceId: createdService?.id || null,
1488
+ serviceName: createdService?.name || config.serviceName,
1489
+ };
1490
+
1491
+ return {
1492
+ key: config.serviceName,
1493
+ serviceName: createdService?.name || config.serviceName,
1494
+ status: config.dryRun ? "dry-run" : "created",
1495
+ };
1496
+ }
1497
+
1498
+ async function deployRailwayAppServices(config) {
1499
+ console.log(pc.bold("\nDeployments"));
1500
+
1501
+ const summary = [];
1502
+ for (const spec of RAILWAY_APP_SERVICE_SPECS) {
1503
+ const manifestEntry = config.manifest.appServices?.[spec.key];
1504
+ const service = findRailwayService(config.services, spec.aliases, manifestEntry?.serviceName || spec.serviceName);
1505
+ const targetServiceName = service?.name || manifestEntry?.serviceName || spec.serviceName;
1506
+
1507
+ if (!service && !config.dryRun) {
1508
+ console.log(`- ${pc.yellow(spec.serviceName)} service not found, skipping deployment`);
1509
+ continue;
1510
+ }
1511
+
1512
+ if (config.dryRun) {
1513
+ console.log(`- would deploy ${pc.cyan(targetServiceName)} from ${spec.directory}/`);
1514
+ summary.push({ directory: spec.directory, serviceName: targetServiceName, status: "dry-run" });
1515
+ continue;
1516
+ }
1517
+
1518
+ console.log(`- deploying ${pc.cyan(targetServiceName)} from ${spec.directory}/...`);
1519
+ await runRailwayCommand(config.projectDir, config.railwayContext.environmentRef, [
1520
+ "up",
1521
+ spec.directory,
1522
+ "--service",
1523
+ targetServiceName,
1524
+ "--path-as-root",
1525
+ "--detach",
1526
+ "--message",
1527
+ `asaje setup-railway: deploy ${targetServiceName}`,
1528
+ ]);
1529
+ summary.push({ directory: spec.directory, serviceName: targetServiceName, status: "deployed" });
1530
+ }
1531
+
1532
+ return summary;
1533
+ }
1534
+
1535
+ async function wireRailwayVariables(config) {
1536
+ console.log(pc.bold("\nVariables"));
1537
+
1538
+ const localEnv = await loadRailwayLocalEnvDefaults(config.projectDir);
1539
+
1540
+ const infra = {
1541
+ objectStorage: findRailwayService(
1542
+ config.services,
1543
+ ["object-storage", "storage", "simple-s3", "minio"],
1544
+ config.manifest.resources.objectStorage?.serviceName,
1545
+ ),
1546
+ postgres: findRailwayService(
1547
+ config.services,
1548
+ ["postgres", "postgresql"],
1549
+ config.manifest.resources.postgres?.serviceName,
1550
+ ),
1551
+ rabbitmq: findRailwayService(
1552
+ config.services,
1553
+ ["rabbitmq"],
1554
+ config.manifest.resources.rabbitmq?.serviceName,
1555
+ ),
1556
+ };
1557
+ const appServices = {
1558
+ admin: findRailwayService(config.services, ["admin", "frontend", "web"], config.manifest.appServices?.admin?.serviceName),
1559
+ api: findRailwayService(config.services, ["api", "backend", "server"], config.manifest.appServices?.api?.serviceName),
1560
+ realtime: findRailwayService(
1561
+ config.services,
1562
+ ["realtime-gateway", "realtime"],
1563
+ config.manifest.appServices?.realtime?.serviceName,
1564
+ ),
1565
+ };
1566
+
1567
+ updateRailwayManifestAppServices(config.manifest, Object.values(appServices).filter(Boolean));
1568
+
1569
+ if (!appServices.api) {
1570
+ console.log(`- ${pc.yellow("api")} service not found, skipping API variable wiring`);
1571
+ }
1572
+ if (!appServices.realtime) {
1573
+ console.log(`- ${pc.yellow("realtime-gateway")} service not found, skipping realtime variable wiring`);
1574
+ }
1575
+ if (!appServices.admin) {
1576
+ console.log(`- ${pc.yellow("admin")} service not found, skipping admin variable wiring`);
1577
+ }
1578
+ if (!infra.postgres) {
1579
+ console.log(`- ${pc.yellow("postgres")} resource not found, DATABASE_URL wiring will be skipped`);
1580
+ }
1581
+ if (!infra.rabbitmq) {
1582
+ console.log(`- ${pc.yellow("rabbitmq")} resource not found, RABBITMQ_URL wiring will be skipped`);
1583
+ }
1584
+ if (!infra.objectStorage) {
1585
+ console.log(`- ${pc.yellow("object-storage")} resource not found, S3 variable wiring will be skipped`);
1586
+ }
1587
+
1588
+ if (appServices.api) {
1589
+ const existingApiVariables = await loadRailwayServiceVariables(
1590
+ config.projectDir,
1591
+ config.railwayContext.environmentRef,
1592
+ appServices.api.name,
1593
+ );
1594
+ const variables = {};
1595
+ const sharedSecrets = buildRailwaySharedSecrets(localEnv, existingApiVariables);
1596
+ Object.assign(variables, sharedSecrets.api);
1597
+ if (infra.postgres?.name) {
1598
+ variables.DATABASE_URL = railwayReference(infra.postgres.name, "DATABASE_URL");
1599
+ }
1600
+ if (infra.rabbitmq?.name) {
1601
+ variables.RABBITMQ_URL = buildRabbitMqUrlReference(infra.rabbitmq.name);
1602
+ }
1603
+ if (infra.objectStorage?.name) {
1604
+ Object.assign(variables, buildObjectStorageVariables(infra.objectStorage.name));
1605
+ }
1606
+ if (appServices.admin?.name) {
1607
+ variables.CORS_ALLOWED_ORIGINS = `https://${railwayReference(appServices.admin.name, "RAILWAY_PUBLIC_DOMAIN")}`;
1608
+ }
1609
+ await applyRailwayVariables({
1610
+ dryRun: config.dryRun,
1611
+ environment: config.railwayContext.environmentRef,
1612
+ projectDir: config.projectDir,
1613
+ serviceName: appServices.api.name,
1614
+ summary: config.summary,
1615
+ variables,
1616
+ });
1617
+ }
1618
+
1619
+ if (appServices.realtime) {
1620
+ const variables = {};
1621
+ Object.assign(variables, buildRealtimeDefaults(localEnv));
1622
+ if (infra.rabbitmq?.name) {
1623
+ variables.RABBITMQ_URL = buildRabbitMqUrlReference(infra.rabbitmq.name);
1624
+ }
1625
+ if (appServices.api?.name) {
1626
+ variables.JWT_SECRET = railwayReference(appServices.api.name, "JWT_SECRET");
1627
+ }
1628
+ if (appServices.admin?.name) {
1629
+ variables.CORS_ALLOWED_ORIGINS = `https://${railwayReference(appServices.admin?.name || "admin", "RAILWAY_PUBLIC_DOMAIN")}`;
1630
+ }
1631
+ await applyRailwayVariables({
1632
+ dryRun: config.dryRun,
1633
+ environment: config.railwayContext.environmentRef,
1634
+ projectDir: config.projectDir,
1635
+ serviceName: appServices.realtime.name,
1636
+ summary: config.summary,
1637
+ variables,
1638
+ });
1639
+ }
1640
+
1641
+ if (appServices.admin) {
1642
+ const variables = {};
1643
+ Object.assign(variables, buildAdminDefaults(localEnv));
1644
+ if (appServices.api?.name) {
1645
+ variables.VITE_API_BASE_URL = `https://${railwayReference(appServices.api.name, "RAILWAY_PUBLIC_DOMAIN")}/api/v1`;
1646
+ }
1647
+ if (appServices.realtime?.name) {
1648
+ variables.VITE_REALTIME_BASE_URL = `https://${railwayReference(appServices.realtime.name, "RAILWAY_PUBLIC_DOMAIN")}`;
1649
+ }
1650
+ await applyRailwayVariables({
1651
+ dryRun: config.dryRun,
1652
+ environment: config.railwayContext.environmentRef,
1653
+ projectDir: config.projectDir,
1654
+ serviceName: appServices.admin.name,
1655
+ summary: config.summary,
1656
+ variables,
1657
+ });
1658
+ }
1659
+ }
1660
+
1661
+ async function loadRailwayLocalEnvDefaults(projectDir) {
1662
+ const [apiEnv, realtimeEnv, adminEnv] = await Promise.all([
1663
+ tryReadEnvFile(path.join(projectDir, "api/.env")),
1664
+ tryReadEnvFile(path.join(projectDir, "realtime-gateway/.env")),
1665
+ tryReadEnvFile(path.join(projectDir, "admin/.env")),
1666
+ ]);
1667
+
1668
+ return {
1669
+ admin: adminEnv,
1670
+ api: apiEnv,
1671
+ realtime: realtimeEnv,
1672
+ };
1673
+ }
1674
+
1675
+ function buildRailwaySharedSecrets(localEnv, existingVariables) {
1676
+ const jwtSecret =
1677
+ sanitizeSecret(localEnv.api.JWT_SECRET, "change-me") ||
1678
+ sanitizeSecret(existingVariables.JWT_SECRET, "${{ api.JWT_SECRET }}") ||
1679
+ randomSecret(32);
1680
+ const swaggerPassword =
1681
+ sanitizeSecret(localEnv.api.SWAGGER_PASSWORD, "change-me-too") ||
1682
+ sanitizeSecret(existingVariables.SWAGGER_PASSWORD, "${{ api.SWAGGER_PASSWORD }}") ||
1683
+ randomSecret(18);
1684
+
1685
+ return {
1686
+ api: {
1687
+ ACCESS_TOKEN_TTL_MINUTES: localEnv.api.ACCESS_TOKEN_TTL_MINUTES || "60",
1688
+ DEFAULT_LOCALE: localEnv.api.DEFAULT_LOCALE || "fr",
1689
+ FILE_MAX_SIZE_MB: localEnv.api.FILE_MAX_SIZE_MB || "25",
1690
+ FILE_SIGNED_URL_TTL_MINUTES: localEnv.api.FILE_SIGNED_URL_TTL_MINUTES || "15",
1691
+ JWT_SECRET: jwtSecret,
1692
+ LOGIN_OTP_TTL_MINUTES: localEnv.api.LOGIN_OTP_TTL_MINUTES || "10",
1693
+ MAILCHIMP_TRANSACTIONAL_API_KEY: localEnv.api.MAILCHIMP_TRANSACTIONAL_API_KEY || "dev-placeholder-key",
1694
+ MAIL_FROM_EMAIL: localEnv.api.MAIL_FROM_EMAIL || "no-reply@example.com",
1695
+ MAIL_FROM_NAME: localEnv.api.MAIL_FROM_NAME || "Boilerplate API",
1696
+ PASSWORD_RESET_OTP_TTL_MINUTES: localEnv.api.PASSWORD_RESET_OTP_TTL_MINUTES || "15",
1697
+ RABBITMQ_REALTIME_EXCHANGE: localEnv.api.RABBITMQ_REALTIME_EXCHANGE || "boilerplate.realtime",
1698
+ RABBITMQ_TASKS_EXCHANGE: localEnv.api.RABBITMQ_TASKS_EXCHANGE || "boilerplate.tasks",
1699
+ RABBITMQ_WORKER_CONSUMER_TAG: localEnv.api.RABBITMQ_WORKER_CONSUMER_TAG || "api-worker",
1700
+ RABBITMQ_WORKER_QUEUE: localEnv.api.RABBITMQ_WORKER_QUEUE || "api.worker.default",
1701
+ RATE_LIMIT_BURST: localEnv.api.RATE_LIMIT_BURST || "60",
1702
+ RATE_LIMIT_RPM: localEnv.api.RATE_LIMIT_RPM || "120",
1703
+ REFRESH_TOKEN_TTL_MINUTES: localEnv.api.REFRESH_TOKEN_TTL_MINUTES || "10080",
1704
+ SWAGGER_PASSWORD: swaggerPassword,
1705
+ SWAGGER_USERNAME: localEnv.api.SWAGGER_USERNAME || "swagger",
1706
+ },
1707
+ };
1708
+ }
1709
+
1710
+ function buildRealtimeDefaults(localEnv) {
1711
+ return {
1712
+ RABBITMQ_REALTIME_EXCHANGE: localEnv.realtime.RABBITMQ_REALTIME_EXCHANGE || "boilerplate.realtime",
1713
+ REALTIME_HEARTBEAT_SECONDS: localEnv.realtime.REALTIME_HEARTBEAT_SECONDS || "25",
1714
+ REALTIME_INSTANCE_ID: localEnv.realtime.REALTIME_INSTANCE_ID || "realtime-gateway-railway",
1715
+ REALTIME_QUEUE_PREFIX: localEnv.realtime.REALTIME_QUEUE_PREFIX || "realtime-gateway",
1716
+ REALTIME_WRITE_TIMEOUT_SECONDS: localEnv.realtime.REALTIME_WRITE_TIMEOUT_SECONDS || "10",
1717
+ };
1718
+ }
1719
+
1720
+ function buildAdminDefaults(localEnv) {
1721
+ return {
1722
+ VITE_API_TIMEOUT_MS: localEnv.admin.VITE_API_TIMEOUT_MS || "15000",
1723
+ VITE_APP_NAME: localEnv.admin.VITE_APP_NAME || "Admin Blueprint",
1724
+ VITE_REALTIME_DEFAULT_TRANSPORT: localEnv.admin.VITE_REALTIME_DEFAULT_TRANSPORT || "sse",
1725
+ VITE_REALTIME_RECONNECT_DELAY_MS: localEnv.admin.VITE_REALTIME_RECONNECT_DELAY_MS || "3000",
1726
+ };
1727
+ }
1728
+
1729
+ function sanitizeSecret(value, placeholder) {
1730
+ const normalized = String(value || "").trim();
1731
+ if (!normalized || normalized === placeholder) {
1732
+ return "";
1733
+ }
1734
+ return normalized;
1735
+ }
1736
+
1737
+ async function tryReadEnvFile(filePath) {
1738
+ if (!(await fs.pathExists(filePath))) {
1739
+ return {};
1740
+ }
1741
+
1742
+ return readEnvFile(filePath);
1743
+ }
1744
+
1745
+ function buildObjectStorageVariables(serviceName) {
1746
+ return {
1747
+ MINIO_ACCESS_KEY: railwayReference(serviceName, "MINIO_ROOT_USER"),
1748
+ MINIO_BUCKET_NAME: railwayReference(serviceName, "MINIO_BUCKET"),
1749
+ MINIO_ENDPOINT: railwayReference(serviceName, "RAILWAY_PRIVATE_DOMAIN"),
1750
+ MINIO_PORT: "9000",
1751
+ MINIO_PUBLIC_URL: `https://${railwayReference(serviceName, "RAILWAY_PUBLIC_DOMAIN")}`,
1752
+ MINIO_SECRET_KEY: railwayReference(serviceName, "MINIO_ROOT_PASSWORD"),
1753
+ MINIO_USE_SSL: "false",
1754
+ OBJECT_STORAGE_PROVIDER: "minio",
1755
+ };
1756
+ }
1757
+
1758
+ function buildRabbitMqUrlReference(serviceName) {
1759
+ return `amqp://${railwayReference(serviceName, "RABBITMQ_DEFAULT_USER")}:${railwayReference(serviceName, "RABBITMQ_DEFAULT_PASS")}@${railwayReference(serviceName, "RAILWAY_PRIVATE_DOMAIN")}:5672/`;
1760
+ }
1761
+
1762
+ function railwayReference(serviceName, variableName) {
1763
+ return "${{ " + serviceName + "." + variableName + " }}";
1764
+ }
1765
+
1766
+ async function applyRailwayVariables(config) {
1767
+ const entries = Object.entries(config.variables).filter(([, value]) => typeof value === "string" && value.length > 0);
1768
+ if (entries.length === 0) {
1769
+ console.log(`- ${pc.dim(config.serviceName)} no variables to set`);
1770
+ return;
1771
+ }
1772
+
1773
+ if (config.dryRun) {
1774
+ console.log(`- ${pc.cyan(config.serviceName)} would set ${entries.map(([key]) => key).join(", ")}`);
1775
+ config.summary.push({
1776
+ keys: entries.map(([key]) => key),
1777
+ serviceName: config.serviceName,
1778
+ status: "dry-run",
1779
+ });
1780
+ return;
1781
+ }
1782
+
1783
+ await runRailwayCommand(config.projectDir, config.environment, [
1784
+ "variable",
1785
+ "set",
1786
+ ...entries.map(([key, value]) => `${key}=${value}`),
1787
+ "--service",
1788
+ config.serviceName,
1789
+ ]);
1790
+ console.log(`- ${pc.cyan(config.serviceName)} set ${entries.map(([key]) => key).join(", ")}`);
1791
+ config.summary.push({
1792
+ keys: entries.map(([key]) => key),
1793
+ serviceName: config.serviceName,
1794
+ status: "updated",
1795
+ });
1796
+ }
1797
+
1798
+ async function loadRailwayServiceVariables(projectDir, environment, serviceName) {
1799
+ try {
1800
+ const result = await execa(
1801
+ "railway",
1802
+ buildRailwayArgs(["variable", "list", "--json", "--service", serviceName], environment),
1803
+ {
1804
+ cwd: projectDir,
1805
+ reject: false,
1806
+ },
1807
+ );
1808
+
1809
+ if (result.exitCode !== 0) {
1810
+ return {};
1811
+ }
1812
+
1813
+ return normalizeRailwayVariables(parseJsonOutput(result.stdout));
1814
+ } catch {
1815
+ return {};
1816
+ }
1817
+ }
1818
+
1819
+ async function waitForCreatedRailwayService(config) {
1820
+ for (let attempt = 0; attempt < RAILWAY_SERVICE_DISCOVERY_RETRY_COUNT; attempt += 1) {
1821
+ const servicesAfter = await discoverRailwayServices(config.railwayContext, config.projectDir);
1822
+ const createdService = findCreatedRailwayService({
1823
+ aliases: config.aliases,
1824
+ beforeServices: config.beforeServices,
1825
+ manifestServiceName: config.manifestEntry?.serviceName,
1826
+ servicesAfter,
1827
+ });
1828
+
1829
+ if (createdService) {
1830
+ return createdService;
1831
+ }
1832
+
1833
+ if (attempt < RAILWAY_SERVICE_DISCOVERY_RETRY_COUNT - 1) {
1834
+ await sleep(RAILWAY_SERVICE_DISCOVERY_RETRY_DELAY_MS);
1835
+ }
1836
+ }
1837
+
1838
+ throw new Error(
1839
+ `Railway command for ${config.key} finished but the new service was not detected afterwards. Check the Railway dashboard/logs, then rerun \`asaje setup-railway\` or \`asaje sync-railway-env\` once the service appears.`,
1840
+ );
1841
+ }
1842
+
1843
+ function findRailwayService(services, aliases, preferredName) {
1844
+ if (preferredName) {
1845
+ const exact = services.find(
1846
+ (service) => normalizeRailwayServiceName(service.name) === normalizeRailwayServiceName(preferredName),
1847
+ );
1848
+ if (exact) {
1849
+ return exact;
1850
+ }
1851
+ }
1852
+
1853
+ const normalizedAliases = aliases.map(normalizeRailwayServiceName);
1854
+ return services.find((service) => {
1855
+ const normalizedName = normalizeRailwayServiceName(service.name);
1856
+ return normalizedAliases.some((alias) => normalizedName === alias || normalizedName.includes(alias));
1857
+ });
1858
+ }
1859
+
1860
+ function normalizeRailwayServiceName(value) {
1861
+ return String(value || "")
1862
+ .trim()
1863
+ .toLowerCase()
1864
+ .replace(/[^a-z0-9]+/g, "-")
1865
+ .replace(/^-+|-+$/g, "");
1866
+ }
1867
+
1868
+ function normalizeRailwayServices(services) {
1869
+ const seen = new Set();
1870
+ const normalized = [];
1871
+
1872
+ for (const service of services) {
1873
+ const name = pickFirstString([service.name, service.serviceName]);
1874
+ if (!name) {
1875
+ continue;
1876
+ }
1877
+
1878
+ const id = pickFirstString([service.id, service.serviceId]);
1879
+ const key = `${normalizeRailwayServiceName(name)}:${id || ""}`;
1880
+ if (seen.has(key)) {
1881
+ continue;
1882
+ }
1883
+
1884
+ seen.add(key);
1885
+ normalized.push({ id, name });
1886
+ }
1887
+
1888
+ return normalized;
1889
+ }
1890
+
1891
+ function findCreatedRailwayService(config) {
1892
+ const beforeServices = normalizeRailwayServices(config.beforeServices);
1893
+ const afterServices = normalizeRailwayServices(config.servicesAfter);
1894
+ const beforeKeys = new Set(beforeServices.map(createRailwayServiceIdentity));
1895
+ const newServices = afterServices.filter((service) => !beforeKeys.has(createRailwayServiceIdentity(service)));
1896
+
1897
+ if (newServices.length === 1) {
1898
+ return newServices[0];
1899
+ }
1900
+
1901
+ const aliasMatch = findRailwayService(newServices, config.aliases, config.manifestServiceName);
1902
+ if (aliasMatch) {
1903
+ return aliasMatch;
1904
+ }
1905
+
1906
+ return null;
1907
+ }
1908
+
1909
+ function createRailwayServiceIdentity(service) {
1910
+ if (service?.id) {
1911
+ return `id:${service.id}`;
1912
+ }
1913
+
1914
+ return `name:${normalizeRailwayServiceName(service?.name)}`;
1915
+ }
1916
+
1917
+ function normalizeRailwayVariables(input) {
1918
+ const normalized = {};
1919
+
1920
+ visitRailwayJson(input, (value) => {
1921
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1922
+ return;
1923
+ }
1924
+
1925
+ const key = pickFirstString([value.name, value.key]);
1926
+ const rawValue = pickFirstString([value.value, value.resolvedValue]);
1927
+ if (!key || rawValue === null) {
1928
+ return;
1929
+ }
1930
+
1931
+ normalized[key] = rawValue;
1932
+ });
1933
+
1934
+ return normalized;
1935
+ }
1936
+
1937
+ function updateRailwayManifestAppServices(manifest, services) {
1938
+ manifest.appServices ||= {};
1939
+
1940
+ const entries = [
1941
+ ["api", findRailwayService(services, ["api", "backend", "server"], manifest.appServices.api?.serviceName)],
1942
+ ["admin", findRailwayService(services, ["admin", "frontend", "web"], manifest.appServices.admin?.serviceName)],
1943
+ [
1944
+ "realtime",
1945
+ findRailwayService(
1946
+ services,
1947
+ ["realtime-gateway", "realtime"],
1948
+ manifest.appServices.realtime?.serviceName,
1949
+ ),
1950
+ ],
1951
+ ];
1952
+
1953
+ for (const [key, service] of entries) {
1954
+ if (!service?.name) {
1955
+ continue;
1956
+ }
1957
+
1958
+ manifest.appServices[key] = {
1959
+ serviceId: service.id || manifest.appServices[key]?.serviceId || null,
1960
+ serviceName: service.name,
1961
+ };
1962
+ }
1963
+ }
1964
+
1965
+ function extractRailwayServiceCandidates(input) {
1966
+ const candidates = [];
1967
+
1968
+ visitRailwayJson(input, (value) => {
1969
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1970
+ return;
1971
+ }
1972
+
1973
+ if (typeof value.name === "string" || typeof value.serviceName === "string") {
1974
+ candidates.push(value);
1975
+ }
1976
+ });
1977
+
1978
+ return candidates;
1979
+ }
1980
+
1981
+ function visitRailwayJson(input, visitor) {
1982
+ visitor(input);
1983
+ if (!input || typeof input !== "object") {
1984
+ return;
1985
+ }
1986
+
1987
+ if (Array.isArray(input)) {
1988
+ for (const item of input) {
1989
+ visitRailwayJson(item, visitor);
1990
+ }
1991
+ return;
1992
+ }
1993
+
1994
+ for (const value of Object.values(input)) {
1995
+ visitRailwayJson(value, visitor);
1996
+ }
1997
+ }
1998
+
1999
+ function sleep(delayMs) {
2000
+ return new Promise((resolve) => {
2001
+ setTimeout(resolve, delayMs);
2002
+ });
2003
+ }
2004
+
2005
+ async function runRailwayCommand(projectDir, environment, args) {
2006
+ const result = await execa("railway", buildRailwayArgs(args, environment), {
2007
+ cwd: projectDir,
2008
+ reject: false,
2009
+ stderr: "inherit",
2010
+ stdout: "inherit",
2011
+ });
2012
+
2013
+ if (result.exitCode !== 0) {
2014
+ throw new Error(`Railway command failed: railway ${args.join(" ")}`);
2015
+ }
2016
+ }
2017
+
2018
+ function buildRailwayArgs(args, environment) {
2019
+ if (!environment) {
2020
+ return args;
2021
+ }
2022
+
2023
+ return [...args, "--environment", environment];
2024
+ }
2025
+
2026
+ function parseJsonOutput(value) {
2027
+ try {
2028
+ return JSON.parse(value);
2029
+ } catch {
2030
+ return null;
2031
+ }
2032
+ }
2033
+
2034
+ function pickFirstString(values) {
2035
+ for (const value of values) {
2036
+ if (typeof value === "string" && value.trim().length > 0) {
2037
+ return value.trim();
2038
+ }
2039
+ }
2040
+
2041
+ return null;
2042
+ }
2043
+
2044
+ function findFirstNestedValue(input, key) {
2045
+ if (!input || typeof input !== "object") {
2046
+ return null;
2047
+ }
2048
+
2049
+ if (typeof input[key] === "string") {
2050
+ return input[key];
2051
+ }
2052
+
2053
+ for (const value of Object.values(input)) {
2054
+ if (!value || typeof value !== "object") {
2055
+ continue;
2056
+ }
2057
+
2058
+ const nested = findFirstNestedValue(value, key);
2059
+ if (nested) {
2060
+ return nested;
2061
+ }
2062
+ }
2063
+
2064
+ return null;
2065
+ }
2066
+
2067
+ function printRailwaySetupSummary(config) {
2068
+ console.log(pc.bold("\nRailway"));
2069
+ console.log(`- Directory: ${pc.bold(config.projectDir)}`);
2070
+ if (config.railwayContext.projectName || config.railwayContext.projectId) {
2071
+ console.log(`- Project: ${pc.bold(config.railwayContext.projectName || config.railwayContext.projectId)}`);
2072
+ }
2073
+ if (config.railwayContext.environmentName || config.railwayContext.environmentId) {
2074
+ console.log(`- Environment: ${pc.bold(config.railwayContext.environmentName || config.railwayContext.environmentId)}`);
2075
+ }
2076
+ console.log(`- Bucket: ${pc.bold(config.bucket)}`);
2077
+
2078
+ console.log(pc.bold("\nResources"));
2079
+ if (config.resourceSummary.length === 0) {
2080
+ console.log("- No infrastructure resources were changed");
2081
+ } else {
2082
+ for (const item of config.resourceSummary) {
2083
+ const detail = item.serviceName ? ` (${item.serviceName})` : "";
2084
+ console.log(`- ${pc.bold(item.key)}: ${item.status}${detail}`);
2085
+ }
2086
+ }
2087
+
2088
+ console.log(pc.bold("\nApplication services"));
2089
+ if (config.appServiceSummary.length === 0) {
2090
+ console.log("- No application services were changed");
2091
+ } else {
2092
+ for (const item of config.appServiceSummary) {
2093
+ const detail = item.serviceName ? ` (${item.serviceName})` : "";
2094
+ console.log(`- ${pc.bold(item.key)}: ${item.status}${detail}`);
2095
+ }
2096
+ }
2097
+
2098
+ console.log(pc.bold("\nDeployments"));
2099
+ if (config.deploySummary.length === 0) {
2100
+ console.log("- No application deployments were triggered");
2101
+ } else {
2102
+ for (const item of config.deploySummary) {
2103
+ console.log(`- ${pc.bold(item.serviceName)}: ${item.status} from ${item.directory}/`);
2104
+ }
2105
+ }
2106
+
2107
+ console.log(pc.bold("\nVariables"));
2108
+ if (config.variableSummary.length === 0) {
2109
+ console.log("- No application variables were updated");
2110
+ } else {
2111
+ for (const item of config.variableSummary) {
2112
+ console.log(`- ${pc.bold(item.serviceName)}: ${item.status} ${item.keys.join(", ")}`);
2113
+ }
2114
+ }
2115
+
2116
+ if (config.dryRun) {
2117
+ console.log(`- Dry run only, ${pc.bold(RAILWAY_MANIFEST_FILENAME)} was not written`);
2118
+ } else {
2119
+ console.log(`- Manifest written to ${pc.bold(RAILWAY_MANIFEST_FILENAME)} for future runs`);
2120
+ }
2121
+
2122
+ if (!getRailwayApiAuth()) {
2123
+ console.log(pc.yellow("\nNote: Set RAILWAY_API_TOKEN or RAILWAY_TOKEN to let future runs verify remote services before provisioning."));
2124
+ }
2125
+ }
2126
+
869
2127
  function parseStartArgs(argv) {
870
2128
  const options = {
871
2129
  directory: ".",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-asaje-go-vue",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "CLI to scaffold, configure, and run the Asaje Go + Vue boilerplate",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,8 @@
11
11
  "create": "node ./bin/create-asaje-go-vue.js",
12
12
  "start": "node ./bin/asaje.js start .",
13
13
  "doctor": "node ./bin/asaje.js doctor ..",
14
+ "setup-railway": "node ./bin/asaje.js setup-railway .. --yes",
15
+ "sync-railway-env": "node ./bin/asaje.js sync-railway-env .. --yes",
14
16
  "publish:check": "node ./bin/asaje.js publish .",
15
17
  "check": "node --check ./bin/create-asaje-go-vue.js && node --check ./bin/asaje.js",
16
18
  "pack:dry-run": "npm pack --dry-run"