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,83 @@
1
+ import { createField, createIR, createResource } from './IRTypes.js';
2
+
3
+ function singularize(name) {
4
+ return name.toLowerCase().replace(/ies$/, 'y').replace(/s$/, '');
5
+ }
6
+
7
+ function extractResourceFromRoute(route) {
8
+ if (!route) return null;
9
+ const cleaned = route.split('?')[0];
10
+ const match = cleaned.match(/\/api\/(?:v\d+\/)?([^/]+)/i);
11
+ if (match?.[1]) return singularize(match[1]);
12
+
13
+ const fallback = cleaned.replace(/^\/+/, '').split('/')[0];
14
+ return fallback ? singularize(fallback) : null;
15
+ }
16
+
17
+ function normalizeRoute(route = '') {
18
+ return route.split('?')[0].trim().replace(/\/+$/, '').toLowerCase();
19
+ }
20
+
21
+ function isHealthProbeCall(call, resourceName) {
22
+ if (resourceName !== 'health') return false;
23
+
24
+ const method = (call.method || 'GET').toUpperCase();
25
+ if (method !== 'GET') return false;
26
+
27
+ const route = normalizeRoute(call.route || '');
28
+ return route === '/health' || route === `/api/health`;
29
+ }
30
+
31
+ export function buildIRFromDetections(apiCalls = [], forms = []) {
32
+ const resourcesMap = new Map();
33
+
34
+ for (const call of apiCalls) {
35
+ const resourceName = extractResourceFromRoute(call.route);
36
+ if (!resourceName || resourceName === 'api') continue;
37
+ if (isHealthProbeCall(call, resourceName)) continue;
38
+
39
+ if (!resourcesMap.has(resourceName)) {
40
+ resourcesMap.set(resourceName, {
41
+ name: resourceName,
42
+ path: `/${resourceName}s`,
43
+ methodSet: new Set(),
44
+ fieldSet: new Set()
45
+ });
46
+ }
47
+
48
+ const resource = resourcesMap.get(resourceName);
49
+ resource.methodSet.add((call.method || 'GET').toUpperCase());
50
+
51
+ for (const field of call.fields || []) {
52
+ if (field) resource.fieldSet.add(field);
53
+ }
54
+ }
55
+
56
+ for (const form of forms) {
57
+ for (const field of form.fields || []) {
58
+ if (!field) continue;
59
+ for (const resource of resourcesMap.values()) {
60
+ resource.fieldSet.add(field);
61
+ }
62
+ }
63
+ }
64
+
65
+ const resources = Array.from(resourcesMap.values()).map((resource) => {
66
+ const fields = Array.from(resource.fieldSet).map((fieldName) => createField(fieldName));
67
+ return createResource({
68
+ name: resource.name,
69
+ path: resource.path,
70
+ fields,
71
+ methods: Array.from(resource.methodSet)
72
+ });
73
+ });
74
+
75
+ return createIR({
76
+ resources,
77
+ relations: [],
78
+ auth: { enabled: false },
79
+ database: 'mongodb'
80
+ });
81
+ }
82
+
83
+ export default buildIRFromDetections;
@@ -0,0 +1,26 @@
1
+ import { parse } from '@babel/parser';
2
+
3
+ export function parseJS(code, sourceFilename = 'unknown.js') {
4
+ try {
5
+ return parse(code, {
6
+ sourceType: 'unambiguous',
7
+ sourceFilename,
8
+ errorRecovery: true,
9
+ plugins: [
10
+ 'jsx',
11
+ 'classProperties',
12
+ 'classPrivateProperties',
13
+ 'classPrivateMethods',
14
+ 'objectRestSpread',
15
+ 'optionalChaining',
16
+ 'nullishCoalescingOperator',
17
+ 'dynamicImport',
18
+ 'topLevelAwait'
19
+ ]
20
+ });
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ export default parseJS;
@@ -0,0 +1,27 @@
1
+ import { parse } from '@babel/parser';
2
+
3
+ export function parseTS(code, sourceFilename = 'unknown.ts') {
4
+ try {
5
+ return parse(code, {
6
+ sourceType: 'unambiguous',
7
+ sourceFilename,
8
+ errorRecovery: true,
9
+ plugins: [
10
+ 'typescript',
11
+ 'jsx',
12
+ 'classProperties',
13
+ 'classPrivateProperties',
14
+ 'classPrivateMethods',
15
+ 'objectRestSpread',
16
+ 'optionalChaining',
17
+ 'nullishCoalescingOperator',
18
+ 'dynamicImport',
19
+ 'topLevelAwait'
20
+ ]
21
+ });
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ export default parseTS;
@@ -0,0 +1,38 @@
1
+ function normalizeResourceName(name) {
2
+ return name.toLowerCase().replace(/ies$/, 'y').replace(/s$/, '');
3
+ }
4
+
5
+ function pascalCase(name) {
6
+ return name.charAt(0).toUpperCase() + name.slice(1);
7
+ }
8
+
9
+ export function applyRelationRules(ir) {
10
+ const resources = ir.resources || [];
11
+ const resourceNames = new Set(resources.map((r) => normalizeResourceName(r.name)));
12
+ const relationSet = new Set();
13
+ const relations = [...(ir.relations || [])];
14
+
15
+ for (const resource of resources) {
16
+ for (const field of resource.fields || []) {
17
+ const match = field.name.match(/^(\w+)Id$/i);
18
+ if (!match) continue;
19
+
20
+ const target = normalizeResourceName(match[1]);
21
+ if (!resourceNames.has(target)) continue;
22
+
23
+ field.type = 'ObjectId';
24
+ field.ref = pascalCase(target);
25
+
26
+ const key = `${resource.name}->${target}`;
27
+ if (!relationSet.has(key)) {
28
+ relationSet.add(key);
29
+ relations.push({ from: pascalCase(resource.name), to: pascalCase(target), type: 'many-to-one' });
30
+ }
31
+ }
32
+ }
33
+
34
+ ir.relations = relations;
35
+ return ir;
36
+ }
37
+
38
+ export default applyRelationRules;
@@ -0,0 +1,32 @@
1
+ import { applySchemaInference } from './schemaInference.js';
2
+ import { applyRelationRules } from './relationRules.js';
3
+
4
+ const CRUD_METHODS = ['GET', 'POST', 'PUT', 'DELETE'];
5
+
6
+ function expandCrudMethods(methods = []) {
7
+ const set = new Set(methods.map((m) => m.toUpperCase()));
8
+
9
+ if (set.has('POST') || set.has('PUT') || set.has('PATCH') || set.has('DELETE')) {
10
+ for (const method of CRUD_METHODS) set.add(method);
11
+ }
12
+
13
+ return Array.from(set).sort();
14
+ }
15
+
16
+ export function applyResourceRules(ir) {
17
+ ir.resources = (ir.resources || []).map((resource) => ({
18
+ ...resource,
19
+ methods: expandCrudMethods(resource.methods)
20
+ }));
21
+ return ir;
22
+ }
23
+
24
+ export function applyRuleEngine(ir) {
25
+ let next = structuredClone(ir);
26
+ next = applyResourceRules(next);
27
+ next = applySchemaInference(next);
28
+ next = applyRelationRules(next);
29
+ return next;
30
+ }
31
+
32
+ export default applyRuleEngine;
@@ -0,0 +1,26 @@
1
+ const FIELD_TYPE_RULES = [
2
+ { pattern: /email/i, type: 'String' },
3
+ { pattern: /price|amount|cost|total|quantity|count|age/i, type: 'Number' },
4
+ { pattern: /date|time|at$/i, type: 'Date' },
5
+ { pattern: /^is[A-Z]|^has[A-Z]|^can[A-Z]|active|enabled|completed|verified/i, type: 'Boolean' },
6
+ { pattern: /id$/i, type: 'ObjectId' }
7
+ ];
8
+
9
+ export function inferFieldType(fieldName) {
10
+ const matched = FIELD_TYPE_RULES.find((rule) => rule.pattern.test(fieldName));
11
+ return matched ? matched.type : 'String';
12
+ }
13
+
14
+ export function applySchemaInference(ir) {
15
+ ir.resources = (ir.resources || []).map((resource) => {
16
+ resource.fields = (resource.fields || []).map((field) => ({
17
+ ...field,
18
+ type: field.type && field.type !== 'String' ? field.type : inferFieldType(field.name)
19
+ }));
20
+ return resource;
21
+ });
22
+
23
+ return ir;
24
+ }
25
+
26
+ export default applySchemaInference;
@@ -0,0 +1,58 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import fg from 'fast-glob';
4
+
5
+ const DEFAULT_PATTERNS = [
6
+ 'src/**/*.{html,js,jsx,ts,tsx}',
7
+ 'app/**/*.{html,js,jsx,ts,tsx}',
8
+ 'pages/**/*.{html,js,jsx,ts,tsx}',
9
+ 'components/**/*.{html,js,jsx,ts,tsx}',
10
+ 'services/**/*.{html,js,jsx,ts,tsx}',
11
+ 'frontend/**/*.{html,js,jsx,ts,tsx}',
12
+ 'client/**/*.{html,js,jsx,ts,tsx}',
13
+ 'public/**/*.{html,js,jsx,ts,tsx}',
14
+ '*.{html,js,jsx,ts,tsx}'
15
+ ];
16
+
17
+ const DEFAULT_IGNORE = [
18
+ '**/node_modules/**',
19
+ '**/.git/**',
20
+ '**/dist/**',
21
+ '**/build/**',
22
+ '**/.next/**',
23
+ '**/coverage/**'
24
+ ];
25
+
26
+ function detectLanguage(ext) {
27
+ if (ext === '.ts' || ext === '.tsx') return 'ts';
28
+ if (ext === '.js' || ext === '.jsx') return 'js';
29
+ if (ext === '.html') return 'html';
30
+ return 'unknown';
31
+ }
32
+
33
+ export function scanProject(projectPath, options = {}) {
34
+ const patterns = options.patterns || DEFAULT_PATTERNS;
35
+ const ignore = options.ignore || DEFAULT_IGNORE;
36
+
37
+ const fullPaths = fg.sync(patterns, {
38
+ cwd: projectPath,
39
+ absolute: true,
40
+ onlyFiles: true,
41
+ unique: true,
42
+ ignore
43
+ });
44
+
45
+ return fullPaths.map((fullPath) => {
46
+ const ext = path.extname(fullPath).toLowerCase();
47
+ const content = fs.readFileSync(fullPath, 'utf8');
48
+ return {
49
+ fullPath,
50
+ relativePath: path.relative(projectPath, fullPath).replace(/\\/g, '/'),
51
+ extension: ext,
52
+ language: detectLanguage(ext),
53
+ content
54
+ };
55
+ });
56
+ }
57
+
58
+ export default scanProject;
@@ -0,0 +1,41 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { deployWithCommand, runCommandCapture, detectBuildOutputDirectory } from './utils.js';
4
+
5
+ async function checkWranglerLogin() {
6
+ try {
7
+ const result = await runCommandCapture({
8
+ command: 'wrangler',
9
+ args: ['whoami'],
10
+ cwd: process.cwd(),
11
+ streamOutput: false
12
+ });
13
+ return !result.stdout.toLowerCase().includes('not logged in');
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ export async function deployToCloudflare(frontendPath, options = {}) {
20
+ const buildDir = options.buildDir || detectBuildOutputDirectory(frontendPath) || 'dist';
21
+ const buildPath = path.join(frontendPath, buildDir);
22
+
23
+ return deployWithCommand({
24
+ providerName: 'Cloudflare Pages',
25
+ command: 'wrangler',
26
+ packageName: 'wrangler',
27
+ args: ['pages', 'deploy', buildDir],
28
+ cwd: frontendPath,
29
+ urlHints: ['pages.dev'],
30
+ loginCheck: checkWranglerLogin,
31
+ loginCommand: { command: 'wrangler', args: ['login'] },
32
+ successLabel: 'Frontend deployed on Cloudflare Pages',
33
+ preflight: async () => {
34
+ if (!fs.existsSync(buildPath)) {
35
+ throw new Error(
36
+ `Build output folder "${buildDir}" was not found in ${frontendPath}. Run your frontend build first.`
37
+ );
38
+ }
39
+ }
40
+ });
41
+ }
@@ -0,0 +1,122 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { deployWithCommand, runCommandCapture } from './utils.js';
4
+
5
+ function sanitizeProjectName(rawValue, fallback = 'offbyt-api') {
6
+ const normalized = String(rawValue || '')
7
+ .toLowerCase()
8
+ .replace(/[^a-z0-9-]/g, '-')
9
+ .replace(/-{2,}/g, '-')
10
+ .replace(/^-+|-+$/g, '');
11
+
12
+ const base = normalized || fallback;
13
+ return base.slice(0, 58);
14
+ }
15
+
16
+ async function checkWranglerLogin() {
17
+ try {
18
+ const result = await runCommandCapture({
19
+ command: 'wrangler',
20
+ args: ['whoami'],
21
+ cwd: process.cwd(),
22
+ streamOutput: false
23
+ });
24
+
25
+ const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
26
+ return !output.includes('not logged in') && !output.includes('authentication') && !output.includes('login');
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ function ensurePagesBackendScaffold(backendPath, projectName) {
33
+ const deployDir = path.join(backendPath, '.offbyt-cloudflare-pages');
34
+ const workerFile = path.join(deployDir, '_worker.js');
35
+ const indexFile = path.join(deployDir, 'index.html');
36
+ const wranglerConfig = path.join(deployDir, 'wrangler.toml');
37
+
38
+ fs.mkdirSync(deployDir, { recursive: true });
39
+
40
+ if (!fs.existsSync(workerFile)) {
41
+ const workerTemplate = `const json = (status, body) => new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json; charset=utf-8' }\n});\n\nexport default {\n async fetch(request) {\n const url = new URL(request.url);\n\n if (request.method === 'GET' && url.pathname === '/') {\n return json(200, {\n success: true,\n message: 'offbyt Cloudflare Pages backend is running'\n });\n }\n\n if (request.method === 'GET' && url.pathname === '/health') {\n return json(200, {\n status: 'ok',\n platform: 'cloudflare-pages',\n timestamp: new Date().toISOString()\n });\n }\n\n if (request.method === 'GET' && url.pathname === '/api/ping') {\n return json(200, { success: true, data: 'pong' });\n }\n\n return json(404, { success: false, message: 'Route not found' });\n }\n};\n`;
42
+
43
+ fs.writeFileSync(workerFile, workerTemplate, 'utf8');
44
+ }
45
+
46
+ if (!fs.existsSync(indexFile)) {
47
+ const html = '<!doctype html><html><head><meta charset="utf-8"><title>offbyt API</title></head><body><h1>offbyt API</h1><p>Project: ' + projectName + '</p></body></html>\n';
48
+ fs.writeFileSync(indexFile, html, 'utf8');
49
+ }
50
+
51
+ const wranglerToml = [
52
+ `name = "${projectName}"`,
53
+ 'pages_build_output_dir = "."',
54
+ 'compatibility_date = "2026-03-06"'
55
+ ].join('\n');
56
+
57
+ fs.writeFileSync(wranglerConfig, `${wranglerToml}\n`, 'utf8');
58
+
59
+ return { deployDir };
60
+ }
61
+
62
+ async function ensurePagesProject(projectName, cwd) {
63
+ try {
64
+ const listResult = await runCommandCapture({
65
+ command: 'wrangler',
66
+ args: ['pages', 'project', 'list'],
67
+ cwd,
68
+ streamOutput: false
69
+ });
70
+
71
+ const listOutput = `${listResult.stdout}\n${listResult.stderr}`.toLowerCase();
72
+ if (listOutput.includes(projectName.toLowerCase())) {
73
+ return;
74
+ }
75
+ } catch {
76
+ // Fall through and try project creation directly.
77
+ }
78
+
79
+ try {
80
+ await runCommandCapture({
81
+ command: 'wrangler',
82
+ args: ['pages', 'project', 'create', projectName, '--production-branch', 'main'],
83
+ cwd,
84
+ streamOutput: true
85
+ });
86
+ } catch (error) {
87
+ const message = String(error.message || '').toLowerCase();
88
+ const alreadyExists = message.includes('already exists') || message.includes('already in use');
89
+
90
+ if (!alreadyExists) {
91
+ throw error;
92
+ }
93
+ }
94
+ }
95
+
96
+ export async function deployToCloudflareWorker(backendPath, options = {}) {
97
+ let projectName = '';
98
+ let deployDir = path.join(backendPath, '.offbyt-cloudflare-pages');
99
+
100
+ const preflight = async () => {
101
+ projectName = sanitizeProjectName(options.projectName || path.basename(backendPath), 'offbyt-api');
102
+ const setup = ensurePagesBackendScaffold(backendPath, projectName);
103
+ deployDir = setup.deployDir;
104
+ await ensurePagesProject(projectName, deployDir);
105
+ };
106
+
107
+ return deployWithCommand({
108
+ providerName: 'Cloudflare Pages',
109
+ command: 'wrangler',
110
+ packageName: 'wrangler',
111
+ args: () => ['pages', 'deploy', '.', '--project-name', projectName, '--commit-dirty=true'],
112
+ cwd: deployDir || backendPath,
113
+ urlHints: ['pages.dev'],
114
+ loginCheck: checkWranglerLogin,
115
+ loginCommand: { command: 'wrangler', args: ['login'] },
116
+ commandNeedsTty: true,
117
+ preflight,
118
+ postDeploy: async () => `https://${projectName}.pages.dev`,
119
+ successLabel: 'Backend deployed on Cloudflare Pages'
120
+ });
121
+ }
122
+
@@ -0,0 +1,198 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import { detectLocalBackendUrl, readPackageJsonSafe } from './utils.js';
5
+
6
+ const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.html']);
7
+ const IGNORE_FOLDERS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.turbo', 'coverage', 'backend']);
8
+
9
+ export function autoConnectDeployment({ projectPath, frontendPath, backendUrl }) {
10
+ const packageJson = readPackageJsonSafe(frontendPath) || {};
11
+ const isVite = detectVite(frontendPath, packageJson);
12
+ const apiVar = isVite ? 'VITE_API_URL' : 'REACT_APP_API_URL';
13
+ const envReference = isVite ? 'import.meta.env.VITE_API_URL' : 'process.env.REACT_APP_API_URL';
14
+ const localBackendUrl = detectLocalBackendUrl(projectPath);
15
+
16
+ const envFilesUpdated = [];
17
+ envFilesUpdated.push(...upsertEnvFile(path.join(frontendPath, '.env.development'), [
18
+ [apiVar, localBackendUrl],
19
+ ['BACKEND_URL_LOCAL', localBackendUrl]
20
+ ]));
21
+
22
+ envFilesUpdated.push(...upsertEnvFile(path.join(frontendPath, '.env.production'), [
23
+ [apiVar, backendUrl],
24
+ ['BACKEND_URL', backendUrl]
25
+ ]));
26
+
27
+ envFilesUpdated.push(...upsertEnvFile(path.join(frontendPath, '.env'), [
28
+ [apiVar, localBackendUrl]
29
+ ]));
30
+
31
+ const sourceFiles = collectFrontendSourceFiles(frontendPath);
32
+
33
+ let updatedFileCount = 0;
34
+
35
+ for (const filePath of sourceFiles) {
36
+ const original = fs.readFileSync(filePath, 'utf8');
37
+ const rewritten = rewriteApiReferences(original, envReference, backendUrl);
38
+
39
+ if (rewritten !== original) {
40
+ fs.writeFileSync(filePath, rewritten, 'utf8');
41
+ updatedFileCount += 1;
42
+ }
43
+ }
44
+
45
+ console.log(chalk.cyan('\nConnecting frontend with backend...\n'));
46
+ console.log(chalk.white(' Local API URL:'), chalk.gray(localBackendUrl));
47
+ console.log(chalk.white(' Production API URL:'), chalk.gray(backendUrl));
48
+ console.log(chalk.white(' Env strategy:'), chalk.gray(`${apiVar} in .env.development + .env.production`));
49
+
50
+ return {
51
+ isVite,
52
+ apiVar,
53
+ localBackendUrl,
54
+ updatedFileCount,
55
+ envFilesUpdated: [...new Set(envFilesUpdated)]
56
+ };
57
+ }
58
+
59
+ function rewriteApiReferences(content, envReference, backendUrl) {
60
+ const envTemplatePrefix = `\${${envReference}}`;
61
+ const backendOrigin = safeOrigin(backendUrl);
62
+
63
+ let updated = content;
64
+
65
+ // Replace relative /api URLs with environment-based base URL.
66
+ updated = updated.replace(/(["'`])(\/api(?:\/[^"'`\n]*)?)\1/g, (_match, _quote, apiPath) => {
67
+ return `\`${envTemplatePrefix}${apiPath}\``;
68
+ });
69
+
70
+ // Replace localhost absolute API URLs.
71
+ updated = updated.replace(
72
+ /(["'`])https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):\d+(\/api(?:\/[^"'`\n]*)?)\1/g,
73
+ (_match, _quote, apiPath) => {
74
+ return `\`${envTemplatePrefix}${apiPath}\``;
75
+ }
76
+ );
77
+
78
+ // Replace previously deployed absolute API URLs when redeploying to a new host.
79
+ if (backendOrigin) {
80
+ const escapedOrigin = escapeRegex(backendOrigin);
81
+ const absoluteApiRegex = new RegExp(
82
+ "([\"'`])" + escapedOrigin + "(\\/api(?:\\/[^\"'`\\n]*)?)\\1",
83
+ 'g'
84
+ );
85
+ updated = updated.replace(absoluteApiRegex, (_match, _quote, apiPath) => {
86
+ return `\`${envTemplatePrefix}${apiPath}\``;
87
+ });
88
+ }
89
+
90
+ // Replace hardcoded API base URL constants (without /api suffix).
91
+ updated = updated.replace(
92
+ /(["'`])https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):\d+\1/g,
93
+ envReference
94
+ );
95
+
96
+ return updated;
97
+ }
98
+
99
+ function detectVite(frontendPath, packageJson) {
100
+ const deps = packageJson.dependencies || {};
101
+ const devDeps = packageJson.devDependencies || {};
102
+
103
+ if (deps.vite || devDeps.vite) {
104
+ return true;
105
+ }
106
+
107
+ const viteConfigFiles = ['vite.config.js', 'vite.config.ts', 'vite.config.mjs', 'vite.config.cjs'];
108
+ return viteConfigFiles.some((file) => fs.existsSync(path.join(frontendPath, file)));
109
+ }
110
+
111
+ function collectFrontendSourceFiles(frontendPath) {
112
+ const candidates = [
113
+ path.join(frontendPath, 'src'),
114
+ path.join(frontendPath, 'app'),
115
+ path.join(frontendPath, 'pages'),
116
+ path.join(frontendPath, 'components')
117
+ ];
118
+
119
+ if (candidates.every((candidate) => !fs.existsSync(candidate))) {
120
+ candidates.push(frontendPath);
121
+ }
122
+
123
+ const files = [];
124
+
125
+ for (const candidate of candidates) {
126
+ if (fs.existsSync(candidate)) {
127
+ walk(candidate, files);
128
+ }
129
+ }
130
+
131
+ return [...new Set(files)];
132
+ }
133
+
134
+ function walk(currentPath, files) {
135
+ const stat = fs.statSync(currentPath);
136
+
137
+ if (stat.isFile()) {
138
+ const extension = path.extname(currentPath).toLowerCase();
139
+ if (SOURCE_EXTENSIONS.has(extension)) {
140
+ files.push(currentPath);
141
+ }
142
+ return;
143
+ }
144
+
145
+ const folderName = path.basename(currentPath).toLowerCase();
146
+ if (IGNORE_FOLDERS.has(folderName)) {
147
+ return;
148
+ }
149
+
150
+ const entries = fs.readdirSync(currentPath, { withFileTypes: true });
151
+ for (const entry of entries) {
152
+ walk(path.join(currentPath, entry.name), files);
153
+ }
154
+ }
155
+
156
+ function upsertEnvFile(filePath, entries) {
157
+ const updated = [];
158
+
159
+ let content = '';
160
+ if (fs.existsSync(filePath)) {
161
+ content = fs.readFileSync(filePath, 'utf8');
162
+ }
163
+
164
+ const lines = content === '' ? [] : content.split(/\r?\n/);
165
+
166
+ for (const [key, value] of entries) {
167
+ const serialized = `${key}=${value}`;
168
+ const index = lines.findIndex((line) => line.startsWith(`${key}=`));
169
+
170
+ if (index >= 0) {
171
+ lines[index] = serialized;
172
+ } else {
173
+ lines.push(serialized);
174
+ }
175
+
176
+ updated.push(filePath);
177
+ }
178
+
179
+ const nextContent = `${lines.filter((line, idx, arr) => {
180
+ if (line.trim() !== '') return true;
181
+ return idx !== arr.length - 1;
182
+ }).join('\n')}\n`;
183
+
184
+ fs.writeFileSync(filePath, nextContent, 'utf8');
185
+ return updated;
186
+ }
187
+
188
+ function safeOrigin(url) {
189
+ try {
190
+ return new URL(url).origin;
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ function escapeRegex(value) {
197
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
198
+ }