offbyt 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +2 -0
  2. package/cli/index.js +2 -0
  3. package/cli.js +206 -0
  4. package/core/detector/detectAxios.js +107 -0
  5. package/core/detector/detectFetch.js +148 -0
  6. package/core/detector/detectForms.js +55 -0
  7. package/core/detector/detectSocket.js +341 -0
  8. package/core/generator/generateControllers.js +17 -0
  9. package/core/generator/generateModels.js +25 -0
  10. package/core/generator/generateRoutes.js +17 -0
  11. package/core/generator/generateServer.js +18 -0
  12. package/core/generator/generateSocket.js +160 -0
  13. package/core/index.js +14 -0
  14. package/core/ir/IRTypes.js +25 -0
  15. package/core/ir/buildIR.js +83 -0
  16. package/core/parser/parseJS.js +26 -0
  17. package/core/parser/parseTS.js +27 -0
  18. package/core/rules/relationRules.js +38 -0
  19. package/core/rules/resourceRules.js +32 -0
  20. package/core/rules/schemaInference.js +26 -0
  21. package/core/scanner/scanProject.js +58 -0
  22. package/deploy/cloudflare.js +41 -0
  23. package/deploy/cloudflareWorker.js +122 -0
  24. package/deploy/connect.js +198 -0
  25. package/deploy/flyio.js +51 -0
  26. package/deploy/index.js +322 -0
  27. package/deploy/netlify.js +29 -0
  28. package/deploy/railway.js +215 -0
  29. package/deploy/render.js +195 -0
  30. package/deploy/utils.js +383 -0
  31. package/deploy/vercel.js +29 -0
  32. package/index.js +18 -0
  33. package/lib/generator/advancedCrudGenerator.js +475 -0
  34. package/lib/generator/crudCodeGenerator.js +486 -0
  35. package/lib/generator/irBasedGenerator.js +360 -0
  36. package/lib/ir-builder/index.js +16 -0
  37. package/lib/ir-builder/irBuilder.js +330 -0
  38. package/lib/ir-builder/rulesEngine.js +353 -0
  39. package/lib/ir-builder/templateEngine.js +193 -0
  40. package/lib/ir-builder/templates/index.js +14 -0
  41. package/lib/ir-builder/templates/model.template.js +47 -0
  42. package/lib/ir-builder/templates/routes-generic.template.js +66 -0
  43. package/lib/ir-builder/templates/routes-user.template.js +105 -0
  44. package/lib/ir-builder/templates/routes.template.js +102 -0
  45. package/lib/ir-builder/templates/validation.template.js +15 -0
  46. package/lib/ir-integration.js +349 -0
  47. package/lib/modes/benchmark.js +162 -0
  48. package/lib/modes/configBasedGenerator.js +2258 -0
  49. package/lib/modes/connect.js +1125 -0
  50. package/lib/modes/doctorAi.js +172 -0
  51. package/lib/modes/generateApi.js +435 -0
  52. package/lib/modes/interactiveSetup.js +548 -0
  53. package/lib/modes/offline.clean.js +14 -0
  54. package/lib/modes/offline.enhanced.js +787 -0
  55. package/lib/modes/offline.js +295 -0
  56. package/lib/modes/offline.v2.js +13 -0
  57. package/lib/modes/sync.js +629 -0
  58. package/lib/scanner/apiEndpointExtractor.js +387 -0
  59. package/lib/scanner/authPatternDetector.js +54 -0
  60. package/lib/scanner/frontendScanner.js +642 -0
  61. package/lib/utils/apiClientGenerator.js +242 -0
  62. package/lib/utils/apiScanner.js +95 -0
  63. package/lib/utils/codeInjector.js +350 -0
  64. package/lib/utils/doctor.js +381 -0
  65. package/lib/utils/envGenerator.js +36 -0
  66. package/lib/utils/loadTester.js +61 -0
  67. package/lib/utils/performanceAnalyzer.js +298 -0
  68. package/lib/utils/resourceDetector.js +281 -0
  69. package/package.json +20 -0
  70. package/templates/.env.template +31 -0
  71. package/templates/advanced.model.template.js +201 -0
  72. package/templates/advanced.route.template.js +341 -0
  73. package/templates/auth.middleware.template.js +87 -0
  74. package/templates/auth.routes.template.js +238 -0
  75. package/templates/auth.user.model.template.js +78 -0
  76. package/templates/cache.middleware.js +34 -0
  77. package/templates/chat.models.template.js +260 -0
  78. package/templates/chat.routes.template.js +478 -0
  79. package/templates/compression.middleware.js +19 -0
  80. package/templates/database.config.js +74 -0
  81. package/templates/errorHandler.middleware.js +54 -0
  82. package/templates/express/controller.ejs +26 -0
  83. package/templates/express/model.ejs +9 -0
  84. package/templates/express/route.ejs +18 -0
  85. package/templates/express/server.ejs +16 -0
  86. package/templates/frontend.env.template +14 -0
  87. package/templates/model.template.js +86 -0
  88. package/templates/package.production.json +51 -0
  89. package/templates/package.template.json +41 -0
  90. package/templates/pagination.utility.js +110 -0
  91. package/templates/production.server.template.js +233 -0
  92. package/templates/rateLimiter.middleware.js +36 -0
  93. package/templates/requestLogger.middleware.js +19 -0
  94. package/templates/response.helper.js +179 -0
  95. package/templates/route.template.js +130 -0
  96. package/templates/security.middleware.js +78 -0
  97. package/templates/server.template.js +91 -0
  98. package/templates/socket.server.template.js +433 -0
  99. package/templates/utils.helper.js +157 -0
  100. package/templates/validation.middleware.js +63 -0
  101. package/templates/validation.schema.js +128 -0
  102. package/utils/fileWriter.js +15 -0
  103. package/utils/logger.js +18 -0
@@ -0,0 +1,51 @@
1
+ import { deployWithCommand, isCommandAvailable, runCommandCapture } from './utils.js';
2
+
3
+ async function checkFlyLogin(command) {
4
+ try {
5
+ const result = await runCommandCapture({
6
+ command,
7
+ args: ['auth', 'whoami'],
8
+ cwd: process.cwd(),
9
+ streamOutput: false
10
+ });
11
+ return !result.stdout.toLowerCase().includes('not logged in');
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ async function resolveFlyCommand() {
18
+ if (await isCommandAvailable('flyctl')) {
19
+ return 'flyctl';
20
+ }
21
+
22
+ if (await isCommandAvailable('fly')) {
23
+ return 'fly';
24
+ }
25
+
26
+ return 'flyctl';
27
+ }
28
+
29
+ const FLY_INSTALL_HINT = [
30
+ 'Install Fly CLI manually, then re-run deploy.',
31
+ 'Windows options:',
32
+ ' winget install --id Fly-io.flyctl -e',
33
+ ' or choco install flyctl',
34
+ ' or scoop install flyctl'
35
+ ].join('\n');
36
+
37
+ export async function deployToFlyIO(backendPath) {
38
+ const flyCommand = await resolveFlyCommand();
39
+
40
+ return deployWithCommand({
41
+ providerName: 'Fly.io',
42
+ command: flyCommand,
43
+ installHint: FLY_INSTALL_HINT,
44
+ args: ['deploy', '--remote-only'],
45
+ cwd: backendPath,
46
+ urlHints: ['fly.dev'],
47
+ loginCheck: () => checkFlyLogin(flyCommand),
48
+ loginCommand: { command: flyCommand, args: ['auth', 'login'] },
49
+ successLabel: 'Backend deployed on Fly.io'
50
+ });
51
+ }
@@ -0,0 +1,322 @@
1
+ import path from 'path';
2
+ import chalk from 'chalk';
3
+ import inquirer from 'inquirer';
4
+ import { deployToVercel } from './vercel.js';
5
+ import { deployToNetlify } from './netlify.js';
6
+ import { deployToCloudflare } from './cloudflare.js';
7
+ import { deployToRailway } from './railway.js';
8
+ import { deployToRender } from './render.js';
9
+ import { deployToCloudflareWorker } from './cloudflareWorker.js';
10
+ import { autoConnectDeployment } from './connect.js';
11
+ import { detectBackendPath, detectFrontendPath, normalizeProviderKey } from './utils.js';
12
+
13
+ const FRONTEND_PROVIDER_CHOICES = [
14
+ { name: 'Vercel', value: 'vercel' },
15
+ { name: 'Netlify', value: 'netlify' },
16
+ { name: 'Cloudflare Pages', value: 'cloudflare' },
17
+ { name: 'Skip frontend', value: 'skip' }
18
+ ];
19
+
20
+ const BACKEND_PROVIDER_CHOICES = [
21
+ { name: 'Railway', value: 'railway' },
22
+ { name: 'Render', value: 'render' },
23
+ { name: 'Cloudflare Pages (Free)', value: 'cloudflare' },
24
+ { name: 'Skip backend', value: 'skip' }
25
+ ];
26
+
27
+ const FRONTEND_DEPLOYERS = {
28
+ vercel: deployToVercel,
29
+ netlify: deployToNetlify,
30
+ cloudflare: deployToCloudflare,
31
+ skip: null
32
+ };
33
+
34
+ const BACKEND_DEPLOYERS = {
35
+ railway: deployToRailway,
36
+ render: deployToRender,
37
+ cloudflare: deployToCloudflareWorker,
38
+ skip: null
39
+ };
40
+
41
+ const FRONTEND_ALIASES = {
42
+ vercel: 'vercel',
43
+ netlify: 'netlify',
44
+ cloudflare: 'cloudflare',
45
+ cloudflarepages: 'cloudflare',
46
+ skip: 'skip',
47
+ skipfrontend: 'skip'
48
+ };
49
+
50
+ const BACKEND_ALIASES = {
51
+ railway: 'railway',
52
+ render: 'render',
53
+ cloudflare: 'cloudflare',
54
+ cloudflareworker: 'cloudflare',
55
+ cloudflareworkers: 'cloudflare',
56
+ worker: 'cloudflare',
57
+ workers: 'cloudflare',
58
+ skip: 'skip',
59
+ skipbackend: 'skip'
60
+ };
61
+
62
+ export async function runDeploymentFlow(projectPath, options = {}) {
63
+ const resolvedProjectPath = path.resolve(projectPath || process.cwd());
64
+
65
+ console.log(chalk.cyan('\noffbyt Deployment\n'));
66
+
67
+ const selection = await resolveProviders(options);
68
+ const frontendPath = detectFrontendPath(resolvedProjectPath);
69
+ const backendPath = detectBackendPath(resolvedProjectPath);
70
+
71
+ const result = {
72
+ frontendProvider: selection.frontend,
73
+ backendProvider: selection.backend,
74
+ frontendUrl: null,
75
+ backendUrl: null,
76
+ connected: null
77
+ };
78
+
79
+ if (selection.frontend && selection.frontend !== 'skip') {
80
+ const deployFrontend = FRONTEND_DEPLOYERS[selection.frontend];
81
+ if (!deployFrontend) {
82
+ throw new Error(`Unsupported frontend provider: ${selection.frontend}`);
83
+ }
84
+
85
+ const frontendDeployResult = await deployFrontend(frontendPath, selection.frontendOptions || {});
86
+ result.frontendUrl = frontendDeployResult.url;
87
+ }
88
+
89
+ if (selection.backend && selection.backend !== 'skip') {
90
+ const deployBackend = BACKEND_DEPLOYERS[selection.backend];
91
+ if (!deployBackend) {
92
+ throw new Error(`Unsupported backend provider: ${selection.backend}`);
93
+ }
94
+
95
+ const backendDeployResult = await deployBackend(backendPath, selection.backendOptions || {});
96
+ result.backendUrl = backendDeployResult.url;
97
+ }
98
+
99
+ if (result.backendUrl && selection.frontend && selection.frontend !== 'skip') {
100
+ result.connected = autoConnectDeployment({
101
+ projectPath: resolvedProjectPath,
102
+ frontendPath,
103
+ backendUrl: result.backendUrl
104
+ });
105
+ }
106
+
107
+ printSummary(result);
108
+ }
109
+
110
+ async function resolveProviders(options) {
111
+ const normalizedFrontend = normalizeFrontendProvider(options.frontend);
112
+ const normalizedBackend = normalizeBackendProvider(options.backend);
113
+ const fallbackBackend = normalizedBackend || 'railway';
114
+
115
+ if (options.full) {
116
+ return {
117
+ frontend: normalizedFrontend || 'vercel',
118
+ backend: fallbackBackend,
119
+ frontendOptions: await resolveFrontendOptions(normalizedFrontend || 'vercel'),
120
+ backendOptions: await resolveBackendOptions(fallbackBackend, options, { interactive: false })
121
+ };
122
+ }
123
+
124
+ const answers = [];
125
+
126
+ if (!normalizedFrontend) {
127
+ answers.push(
128
+ await inquirer.prompt([
129
+ {
130
+ type: 'list',
131
+ name: 'frontend',
132
+ message: 'Select frontend hosting',
133
+ choices: FRONTEND_PROVIDER_CHOICES
134
+ }
135
+ ])
136
+ );
137
+ }
138
+
139
+ if (!normalizedBackend) {
140
+ answers.push(
141
+ await inquirer.prompt([
142
+ {
143
+ type: 'list',
144
+ name: 'backend',
145
+ message: 'Select backend hosting',
146
+ choices: BACKEND_PROVIDER_CHOICES
147
+ }
148
+ ])
149
+ );
150
+ }
151
+
152
+ const frontend = normalizedFrontend || answers.find((entry) => entry.frontend)?.frontend;
153
+ const backend = normalizedBackend || answers.find((entry) => entry.backend)?.backend;
154
+
155
+ return {
156
+ frontend,
157
+ backend,
158
+ frontendOptions: await resolveFrontendOptions(frontend),
159
+ backendOptions: await resolveBackendOptions(backend, options, { interactive: true })
160
+ };
161
+ }
162
+
163
+ function normalizeFrontendProvider(value) {
164
+ if (!value) return null;
165
+ const normalized = normalizeProviderKey(value);
166
+ const provider = FRONTEND_ALIASES[normalized];
167
+
168
+ if (!provider) {
169
+ throw new Error(`Invalid frontend provider: ${value}. Use vercel | netlify | cloudflare`);
170
+ }
171
+
172
+ return provider;
173
+ }
174
+
175
+ function normalizeBackendProvider(value) {
176
+ if (!value) return null;
177
+ const normalized = normalizeProviderKey(value);
178
+ const provider = BACKEND_ALIASES[normalized];
179
+
180
+ if (!provider) {
181
+ throw new Error(`Invalid backend provider: ${value}. Use railway | render | cloudflare | skip`);
182
+ }
183
+
184
+ return provider;
185
+ }
186
+
187
+ async function resolveFrontendOptions(frontendProvider) {
188
+ if (frontendProvider !== 'cloudflare') {
189
+ return {};
190
+ }
191
+
192
+ const answers = await inquirer.prompt([
193
+ {
194
+ type: 'input',
195
+ name: 'buildDir',
196
+ message: 'Build output directory for Cloudflare Pages',
197
+ default: 'dist'
198
+ }
199
+ ]);
200
+
201
+ return {
202
+ buildDir: answers.buildDir
203
+ };
204
+ }
205
+
206
+ function buildDefaultRailwayProjectName() {
207
+ const stamp = new Date()
208
+ .toISOString()
209
+ .replace(/[-:TZ]/g, '')
210
+ .slice(0, 12);
211
+
212
+ return `offbyt-${stamp}`;
213
+ }
214
+
215
+ async function resolveBackendOptions(backendProvider, options = {}, { interactive = false } = {}) {
216
+ if (!backendProvider || backendProvider === 'skip') {
217
+ return {};
218
+ }
219
+
220
+ const cliServiceId = String(options.backendServiceId || process.env.RENDER_SERVICE_ID || '').trim();
221
+
222
+ if (backendProvider === 'render') {
223
+ if (cliServiceId) {
224
+ return { serviceId: cliServiceId };
225
+ }
226
+
227
+ if (!interactive) {
228
+ return {};
229
+ }
230
+
231
+ const answers = await inquirer.prompt([
232
+ {
233
+ type: 'input',
234
+ name: 'serviceId',
235
+ message: 'Render service ID (required)',
236
+ validate: (value) => String(value || '').trim().length > 0 || 'Service ID is required',
237
+ filter: (value) => String(value || '').trim()
238
+ }
239
+ ]);
240
+
241
+ return { serviceId: answers.serviceId };
242
+ }
243
+
244
+ if (backendProvider === 'cloudflare') {
245
+ const cliProjectName = String(options.backendProjectName || process.env.CLOUDFLARE_PAGES_PROJECT || '').trim();
246
+ return cliProjectName ? { projectName: cliProjectName } : {};
247
+ }
248
+
249
+ if (backendProvider !== 'railway') {
250
+ return {};
251
+ }
252
+
253
+ const cliProjectName = String(options.backendProjectName || '').trim();
254
+ const cliServiceName = String(options.backendServiceName || '').trim();
255
+ if (cliProjectName) {
256
+ return {
257
+ projectName: cliProjectName,
258
+ ...(cliServiceName ? { serviceName: cliServiceName } : {})
259
+ };
260
+ }
261
+
262
+ if (!interactive) {
263
+ return {
264
+ projectName: buildDefaultRailwayProjectName(),
265
+ ...(cliServiceName ? { serviceName: cliServiceName } : {})
266
+ };
267
+ }
268
+
269
+ const answers = await inquirer.prompt([
270
+ {
271
+ type: 'input',
272
+ name: 'projectName',
273
+ message: 'Railway project name (auto-created if missing)',
274
+ default: buildDefaultRailwayProjectName(),
275
+ filter: (value) => String(value || '').trim()
276
+ },
277
+ {
278
+ type: 'input',
279
+ name: 'serviceName',
280
+ message: 'Railway service name (optional, leave blank for auto)',
281
+ default: cliServiceName,
282
+ filter: (value) => String(value || '').trim()
283
+ }
284
+ ]);
285
+
286
+ return {
287
+ projectName: answers.projectName || buildDefaultRailwayProjectName(),
288
+ ...(answers.serviceName ? { serviceName: answers.serviceName } : {})
289
+ };
290
+ }
291
+
292
+ function printSummary(result) {
293
+ console.log(chalk.green('\nDeployment complete\n'));
294
+
295
+ if (result.frontendUrl) {
296
+ console.log(chalk.green('✔ Frontend deployed ->'), chalk.white(result.frontendUrl));
297
+ } else {
298
+ console.log(chalk.yellow('• Frontend deployment skipped'));
299
+ }
300
+
301
+ if (result.backendUrl) {
302
+ console.log(chalk.green('✔ Backend deployed ->'), chalk.white(result.backendUrl));
303
+ } else {
304
+ console.log(chalk.yellow('• Backend deployment skipped'));
305
+ }
306
+
307
+ if (result.connected) {
308
+ console.log(chalk.green('\n✔ Frontend connected with backend'));
309
+ console.log(chalk.gray(` Updated source files: ${result.connected.updatedFileCount}`));
310
+ console.log(chalk.gray(` Updated env files: ${result.connected.envFilesUpdated.length}`));
311
+ }
312
+
313
+ console.log(chalk.cyan('\nApp live:'));
314
+ if (result.frontendUrl) {
315
+ console.log(chalk.white(`Frontend -> ${result.frontendUrl}`));
316
+ }
317
+ if (result.backendUrl) {
318
+ console.log(chalk.white(`Backend -> ${result.backendUrl}`));
319
+ }
320
+ console.log('');
321
+ }
322
+
@@ -0,0 +1,29 @@
1
+ import { deployWithCommand, runCommandCapture } from './utils.js';
2
+
3
+ async function checkNetlifyLogin() {
4
+ try {
5
+ const result = await runCommandCapture({
6
+ command: 'netlify',
7
+ args: ['status'],
8
+ cwd: process.cwd(),
9
+ streamOutput: false
10
+ });
11
+ return result.stdout.toLowerCase().includes('logged in');
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ export async function deployToNetlify(frontendPath) {
18
+ return deployWithCommand({
19
+ providerName: 'Netlify',
20
+ command: 'netlify',
21
+ packageName: 'netlify-cli',
22
+ args: ['deploy', '--prod'],
23
+ cwd: frontendPath,
24
+ urlHints: ['netlify.app'],
25
+ loginCheck: checkNetlifyLogin,
26
+ loginCommand: { command: 'netlify', args: ['login'] },
27
+ successLabel: 'Frontend deployed on Netlify'
28
+ });
29
+ }
@@ -0,0 +1,215 @@
1
+ import { deployWithCommand, runCommandCapture } from './utils.js';
2
+
3
+ function normalize(value = '') {
4
+ return String(value).trim().toLowerCase();
5
+ }
6
+
7
+ async function checkRailwayLogin() {
8
+ try {
9
+ const result = await runCommandCapture({
10
+ command: 'railway',
11
+ args: ['whoami'],
12
+ cwd: process.cwd(),
13
+ streamOutput: false
14
+ });
15
+ return result.stdout.trim().length > 0 && !result.stderr.toLowerCase().includes('not logged in');
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ function buildDefaultRailwayProjectName() {
22
+ const stamp = new Date()
23
+ .toISOString()
24
+ .replace(/[-:TZ]/g, '')
25
+ .slice(0, 12);
26
+
27
+ return `offbyt-${stamp}`;
28
+ }
29
+
30
+ async function isRailwayProjectLinked(backendPath) {
31
+ try {
32
+ await runCommandCapture({
33
+ command: 'railway',
34
+ args: ['status'],
35
+ cwd: backendPath,
36
+ streamOutput: false
37
+ });
38
+ return true;
39
+ } catch (error) {
40
+ const message = String(error.message || '').toLowerCase();
41
+ if (message.includes('no linked project found')) {
42
+ return false;
43
+ }
44
+
45
+ // If status check itself fails for other reasons, treat as not linked and let init/link handle it.
46
+ return false;
47
+ }
48
+ }
49
+
50
+ async function ensureRailwayProjectLinked(backendPath, options = {}) {
51
+ const hasLinkedProject = await isRailwayProjectLinked(backendPath);
52
+ const requestedProject = String(options.projectName || '').trim();
53
+
54
+ if (hasLinkedProject && !requestedProject) {
55
+ return;
56
+ }
57
+
58
+ if (requestedProject) {
59
+ try {
60
+ await runCommandCapture({
61
+ command: 'railway',
62
+ args: ['link', '--project', requestedProject],
63
+ cwd: backendPath,
64
+ streamOutput: true
65
+ });
66
+ return;
67
+ } catch {
68
+ // Fall through to create the requested project when link fails.
69
+ }
70
+ }
71
+
72
+ if (hasLinkedProject && !requestedProject && !options.forceRelink) {
73
+ return;
74
+ }
75
+
76
+ const projectName = requestedProject || buildDefaultRailwayProjectName();
77
+ await runCommandCapture({
78
+ command: 'railway',
79
+ args: ['init', '-n', projectName],
80
+ cwd: backendPath,
81
+ streamOutput: true
82
+ });
83
+ }
84
+
85
+ function extractFirstJsonObject(raw = '') {
86
+ const text = String(raw || '').trim();
87
+ const first = text.indexOf('{');
88
+ const last = text.lastIndexOf('}');
89
+
90
+ if (first === -1 || last === -1 || last <= first) {
91
+ return null;
92
+ }
93
+
94
+ return text.slice(first, last + 1);
95
+ }
96
+
97
+ async function getRailwayStatus(backendPath) {
98
+ try {
99
+ const result = await runCommandCapture({
100
+ command: 'railway',
101
+ args: ['status', '--json'],
102
+ cwd: backendPath,
103
+ streamOutput: false
104
+ });
105
+
106
+ const jsonPayload = extractFirstJsonObject(result.stdout);
107
+ if (!jsonPayload) {
108
+ return null;
109
+ }
110
+
111
+ return JSON.parse(jsonPayload);
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ function resolveRailwayServiceName(status, options = {}) {
118
+ const explicitServiceName = String(options.serviceName || '').trim();
119
+ if (explicitServiceName) {
120
+ return explicitServiceName;
121
+ }
122
+
123
+ const serviceEdges = status?.services?.edges || [];
124
+ const serviceNames = serviceEdges
125
+ .map((edge) => edge?.node?.name)
126
+ .filter(Boolean);
127
+
128
+ if (serviceNames.length === 0) {
129
+ return null;
130
+ }
131
+
132
+ const requestedProjectName = String(options.projectName || '').trim();
133
+ if (requestedProjectName) {
134
+ const matched = serviceNames.find((name) => normalize(name) === normalize(requestedProjectName));
135
+ if (matched) {
136
+ return matched;
137
+ }
138
+ }
139
+
140
+ // Use first service as fallback to avoid "multiple services" ambiguity.
141
+ return serviceNames[0];
142
+ }
143
+
144
+ function resolveRailwayDomainFromStatus(status) {
145
+ const envEdges = status?.environments?.edges || [];
146
+ for (const envEdge of envEdges) {
147
+ const serviceInstances = envEdge?.node?.serviceInstances?.edges || [];
148
+ for (const serviceEdge of serviceInstances) {
149
+ const domains = serviceEdge?.node?.domains?.serviceDomains || [];
150
+ const candidate = domains.find((item) => item?.domain)?.domain;
151
+ if (candidate) {
152
+ return `https://${candidate}`;
153
+ }
154
+ }
155
+ }
156
+
157
+ return null;
158
+ }
159
+
160
+ async function getRailwayPublicDomain(backendPath) {
161
+ const status = await getRailwayStatus(backendPath);
162
+ const domainFromStatus = resolveRailwayDomainFromStatus(status);
163
+ if (domainFromStatus) {
164
+ return domainFromStatus;
165
+ }
166
+
167
+ try {
168
+ const result = await runCommandCapture({
169
+ command: 'railway',
170
+ args: ['domain'],
171
+ cwd: backendPath,
172
+ streamOutput: false
173
+ });
174
+
175
+ const combinedOutput = `${result.stdout}\n${result.stderr}`;
176
+ const match = combinedOutput.match(/https?:\/\/[^\s"'`<>]+/i);
177
+ return match ? match[0] : null;
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ export async function deployToRailway(backendPath, options = {}) {
184
+ let serviceName = String(options.serviceName || '').trim();
185
+
186
+ const preflight = async () => {
187
+ await ensureRailwayProjectLinked(backendPath, options);
188
+
189
+ const status = await getRailwayStatus(backendPath);
190
+ if (!serviceName) {
191
+ serviceName = resolveRailwayServiceName(status, options);
192
+ }
193
+ };
194
+
195
+ return deployWithCommand({
196
+ providerName: 'Railway',
197
+ command: 'railway',
198
+ packageName: '@railway/cli',
199
+ args: () => {
200
+ const deployArgs = ['up'];
201
+ if (serviceName) {
202
+ deployArgs.push('--service', serviceName);
203
+ }
204
+ return deployArgs;
205
+ },
206
+ cwd: backendPath,
207
+ urlHints: ['up.railway.app', 'railway.app'],
208
+ loginCheck: checkRailwayLogin,
209
+ loginCommand: { command: 'railway', args: ['login', '--browserless'] },
210
+ preflight,
211
+ postDeploy: async () => getRailwayPublicDomain(backendPath),
212
+ successLabel: 'Backend deployed on Railway'
213
+ });
214
+ }
215
+