@dockerforge/core 0.1.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.
@@ -0,0 +1,1128 @@
1
+ // backend/src/modules/analysis/analyser.js
2
+ // Walks the full project tree, finds every service root, analyses each one.
3
+
4
+ const path = require('path');
5
+ const fs = require('fs-extra');
6
+ const { STACKS, DEFAULT_VERSIONS, DEFAULT_PORTS, ROOT_CONFIG_FILES } = require('../../../../shared/constants');
7
+
8
+ // ── Constants ────────────────────────────────────────────────────────────────
9
+
10
+ // Dirs that are never service roots
11
+ const SKIP_DIRS = new Set([
12
+ 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build',
13
+ '__pycache__', '.pytest_cache', 'bin', 'obj', 'coverage',
14
+ '.venv', 'venv', 'env', '.env', '.idea', '.vscode',
15
+ 'public', 'static', 'assets', 'media', '.cache', 'out', '.output',
16
+ // Test infrastructure — fixtures are not real services
17
+ '__tests__', '__mocks__', 'test', 'tests', 'fixtures', 'spec', 'specs',
18
+ // Sample code and documentation — not deployable services
19
+ 'examples', 'example', 'samples', 'sample', 'demos', 'demo',
20
+ 'docs', 'doc', 'dev-docs', 'documentation', 'storybook-static',
21
+ ]);
22
+
23
+ // Dirs at project root that are shared utilities, not services
24
+ const SHARED_DIR_NAMES = new Set([
25
+ 'shared', 'common', 'lib', 'libs', 'utils', 'core',
26
+ 'packages', 'types', 'helpers', 'internal', 'constants',
27
+ ]);
28
+
29
+
30
+ // Detects root-level config files that exist in the project.
31
+ // Returned list is used by the generator to emit explicit COPY commands.
32
+ async function findRootConfigFiles(projectPath) {
33
+ try {
34
+ const entries = await fs.readdir(projectPath, { withFileTypes: true });
35
+ const fileNames = entries.filter(e => e.isFile()).map(e => e.name);
36
+
37
+ const found = ROOT_CONFIG_FILES.filter(f => fileNames.includes(f));
38
+
39
+ // Capture additional tsconfig.*.json variants that affect compilation (e.g. tsconfig.paths.json).
40
+ // Exclude dev-only variants used by linters/test runners, not the compiler.
41
+ const DEV_TSCONFIG = /eslint|test|spec|jest|vitest|storybook|lint/i;
42
+ const extraTsconfigs = fileNames.filter(
43
+ f => f.startsWith('tsconfig.') && f.endsWith('.json') && !found.includes(f) && !DEV_TSCONFIG.test(f)
44
+ );
45
+
46
+ // Build exclusion set from .dockerignore if present and non-empty.
47
+ // Empty = ingestion placeholder, not a real file.
48
+ const excluded = new Set();
49
+ try {
50
+ const raw = await fs.readFile(path.join(projectPath, '.dockerignore'), 'utf-8');
51
+ if (raw.trim().length > 0) {
52
+ for (const line of raw.split('\n')) {
53
+ const trimmed = line.trim();
54
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) continue;
55
+ const base = path.basename(trimmed);
56
+ if (base && !base.includes('*') && !base.includes('/')) excluded.add(base);
57
+ }
58
+ }
59
+ } catch { /* no .dockerignore */ }
60
+
61
+ // Only include files with real content (not empty placeholders) and not excluded by .dockerignore.
62
+ const result = await Promise.all(
63
+ [...found, ...extraTsconfigs].map(async f => {
64
+ if (excluded.has(f)) return null;
65
+ try {
66
+ const { size } = await fs.stat(path.join(projectPath, f));
67
+ return size > 0 ? f : null;
68
+ } catch { return null; }
69
+ })
70
+ );
71
+
72
+ return result.filter(Boolean);
73
+ } catch {
74
+ return [];
75
+ }
76
+ }
77
+
78
+ // Reads .env.example or .env.sample and returns variable names that have no default value.
79
+ // These are the ones that must be supplied at runtime (ARG/ENV in Dockerfile).
80
+ async function findEnvVars(projectPath) {
81
+ for (const name of ['.env.example', '.env.sample']) {
82
+ try {
83
+ const raw = await fs.readFile(path.join(projectPath, name), 'utf-8');
84
+ if (!raw.trim()) continue;
85
+ const vars = [];
86
+ for (const line of raw.split('\n')) {
87
+ const trimmed = line.trim();
88
+ if (!trimmed || trimmed.startsWith('#')) continue;
89
+ const [key, value] = trimmed.split('=');
90
+ if (key && key.trim()) {
91
+ const trimmedValue = value?.trim() || '';
92
+ vars.push({ key: key.trim(), value: trimmedValue, hasDefault: trimmedValue !== '' });
93
+ }
94
+ }
95
+ return vars;
96
+ } catch { /* file not present */ }
97
+ }
98
+ return [];
99
+ }
100
+
101
+ // Deps that indicate a frontend bundle
102
+ const FRONTEND_DEPS = new Set([
103
+ 'react', 'react-dom', 'vue', '@angular/core', 'next', 'nuxt',
104
+ 'svelte', '@sveltejs/kit', 'gatsby', 'remix', '@remix-run/react',
105
+ 'astro', 'vite', 'webpack', 'parcel', '@vitejs/plugin-react',
106
+ 'solid-js', 'preact',
107
+ ]);
108
+
109
+ // Deps that indicate a backend server
110
+ const BACKEND_DEPS = new Set([
111
+ 'express', 'fastify', 'koa', '@koa/router', 'hapi', '@hapi/hapi',
112
+ 'restify', '@nestjs/core', 'adonis', '@adonisjs/core', 'polka',
113
+ 'connect', 'http', 'https',
114
+ ]);
115
+
116
+ function extractQuotedConfigValue(content, key) {
117
+ const match = content.match(new RegExp(`${key}\\s*:\\s*(['"\`])([^'"\`]+)\\1`));
118
+ return match?.[2] || null;
119
+ }
120
+
121
+ // ── Tree Walker ──────────────────────────────────────────────────────────────
122
+
123
+ // Lockfiles that confirm a directory is a real managed service, not a stub.
124
+ // .NET has no standard lockfile requirement so its list is empty (always passes).
125
+ const LOCKFILE_MAP = {
126
+ [STACKS.NODE]: ['yarn.lock', 'package-lock.json', 'pnpm-lock.yaml'],
127
+ [STACKS.PYTHON]: [], // pip projects have no lockfile; poetry/pipenv detected via their own manifests
128
+ [STACKS.DOTNET]: [],
129
+ };
130
+
131
+ function hasLockfile(fileNames, stack) {
132
+ const required = LOCKFILE_MAP[stack] || [];
133
+ return required.length === 0 || required.some(lf => fileNames.includes(lf));
134
+ }
135
+
136
+ // Returns all service root dirs found in the tree, with their detected stack.
137
+ // Depth capped at 6 so we don't crawl into infinite nesting.
138
+ async function findServiceRoots(projectPath) {
139
+ const roots = new Map(); // absolute dir path → STACKS constant
140
+
141
+ const MANIFEST_MAP = {
142
+ 'package.json': STACKS.NODE,
143
+ 'requirements.txt': STACKS.PYTHON,
144
+ 'pyproject.toml': STACKS.PYTHON,
145
+ 'setup.py': STACKS.PYTHON,
146
+ 'Pipfile': STACKS.PYTHON,
147
+ };
148
+
149
+ // Read root directory files once to detect workspace lockfiles (shared across workspace members)
150
+ let rootFileNames = [];
151
+ try {
152
+ const rootEntries = await fs.readdir(projectPath, { withFileTypes: true });
153
+ rootFileNames = rootEntries.filter(e => e.isFile()).map(e => e.name);
154
+ } catch { /* skip if unreadable */ }
155
+
156
+ const walk = async (dir, depth) => {
157
+ if (depth > 6) return;
158
+
159
+ let entries;
160
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); }
161
+ catch { return; }
162
+
163
+ const fileNames = entries.filter(e => e.isFile()).map(e => e.name);
164
+
165
+ // Check manifests — register as service if:
166
+ // 1. Has own lockfile (standalone package), OR
167
+ // 2. Has manifest + root has lockfile (workspace member) AND has scripts.build or scripts.start
168
+ // (guards against library packages under packages/ that have no own lockfile and are never run directly)
169
+ for (const [manifest, stack] of Object.entries(MANIFEST_MAP)) {
170
+ if (fileNames.includes(manifest) && !roots.has(dir)) {
171
+ const ownLockfile = hasLockfile(fileNames, stack);
172
+ const rootLockfile = dir !== projectPath && hasLockfile(rootFileNames, stack);
173
+ if (ownLockfile) {
174
+ roots.set(dir, stack);
175
+ break;
176
+ }
177
+ if (rootLockfile) {
178
+ // Only treat as a deployable service if it has a runnable script.
179
+ // Library packages (only main/exports, no scripts) are not services.
180
+ let hasRunnableScript = false;
181
+ if (stack === STACKS.NODE) {
182
+ try {
183
+ const pkg = await fs.readJson(path.join(dir, 'package.json'));
184
+ hasRunnableScript = !!(pkg.scripts?.build || pkg.scripts?.start);
185
+ } catch { /* skip unreadable package.json */ }
186
+ } else {
187
+ // Python / .NET have no script gating — root lockfile alone is sufficient
188
+ hasRunnableScript = true;
189
+ }
190
+ if (hasRunnableScript) {
191
+ roots.set(dir, stack);
192
+ break;
193
+ }
194
+ }
195
+ }
196
+ }
197
+ // .csproj — .NET has no standard lockfile requirement
198
+ if (!roots.has(dir) && fileNames.some(f => f.endsWith('.csproj'))) {
199
+ roots.set(dir, STACKS.DOTNET);
200
+ }
201
+
202
+ // Recurse
203
+ for (const entry of entries) {
204
+ if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
205
+ await walk(path.join(dir, entry.name), depth + 1);
206
+ }
207
+ }
208
+ };
209
+
210
+ await walk(projectPath, 0);
211
+
212
+ // If subdirectory services were found, drop any root-level service.
213
+ // A package.json at project root is usually a Vercel/workspace config, not a runnable service.
214
+ if (roots.size > 1 && roots.has(projectPath)) {
215
+ roots.delete(projectPath);
216
+ }
217
+
218
+ return Array.from(roots.entries()).map(([dir, stack]) => ({ dir, stack }));
219
+ }
220
+
221
+ // ── Shared Dir Detection ─────────────────────────────────────────────────────
222
+
223
+ // Dir names that hold static web assets (no package.json — not a managed service)
224
+ const STATIC_DIR_NAMES = new Set([
225
+ 'frontend', 'client', 'web', 'ui', 'html', 'www', 'public',
226
+ ]);
227
+
228
+ // Finds non-service dirs at project root that look like shared utilities.
229
+ async function findSharedDirs(projectPath, serviceRootDirs) {
230
+ const shared = [];
231
+ const staticAssets = [];
232
+ let entries;
233
+ try { entries = await fs.readdir(projectPath, { withFileTypes: true }); }
234
+ catch { return { shared, staticAssets }; }
235
+
236
+ for (const entry of entries) {
237
+ if (!entry.isDirectory()) continue;
238
+ if (SKIP_DIRS.has(entry.name)) continue;
239
+ const abs = path.join(projectPath, entry.name);
240
+ if (serviceRootDirs.includes(abs)) continue;
241
+ // Skip dirs that are parents of service roots (e.g. "packages/" when services are inside it)
242
+ if (serviceRootDirs.some(srd => srd.startsWith(abs + path.sep))) continue;
243
+ const name = entry.name.toLowerCase();
244
+ if (SHARED_DIR_NAMES.has(name)) shared.push(entry.name);
245
+ if (STATIC_DIR_NAMES.has(name)) staticAssets.push(entry.name);
246
+ }
247
+ return { shared, staticAssets };
248
+ }
249
+
250
+ // ── Role Detection ───────────────────────────────────────────────────────────
251
+
252
+ // Returns 'frontend' | 'backend' | 'service'
253
+ async function detectRole(serviceDir, pkg) {
254
+ const dirName = path.basename(serviceDir).toLowerCase();
255
+
256
+ if (/^(frontend|client|web|ui|spa|app)$/.test(dirName)) return 'frontend';
257
+ if (/^(backend|server|api|service|srv)$/.test(dirName)) return 'backend';
258
+
259
+ const allDeps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
260
+
261
+ if (allDeps.some(d => FRONTEND_DEPS.has(d))) return 'frontend';
262
+ if (allDeps.some(d => BACKEND_DEPS.has(d))) return 'backend';
263
+
264
+ if (pkg.scripts?.build && !pkg.scripts?.start) return 'frontend';
265
+ if (pkg.scripts?.start) return 'backend';
266
+
267
+ return 'service';
268
+ }
269
+
270
+ // Dirs that must never appear as part of a deployable service path.
271
+ // Mirrors SKIP_DIRS + IGNORED_DIRS — belt-and-suspenders guard after service detection.
272
+ const NON_DEPLOYABLE_PATH_PARTS = new Set([
273
+ '__tests__', '__mocks__', 'test', 'tests', 'fixtures', 'spec', 'specs',
274
+ 'examples', 'example', 'samples', 'sample', 'demos', 'demo',
275
+ 'docs', 'doc', 'dev-docs', 'documentation',
276
+ ]);
277
+
278
+ // ── Workspace Member Discovery ───────────────────────────────────────────────
279
+
280
+ // Reads root package.json "workspaces" field, glob-expands patterns, and returns
281
+ // relative paths of all workspace member directories that actually have a package.json
282
+ // present in the workdir. Used by the generator to copy ALL workspace package.json
283
+ // files before install, ensuring hoisted devDeps (cross-env, vite, etc.) get installed
284
+ // even when they live in a non-service package (e.g. packages/excalidraw).
285
+ async function findWorkspacePackageDirs(projectPath) {
286
+ try {
287
+ const rootPkg = await fs.readJson(path.join(projectPath, 'package.json'));
288
+ // "workspaces" can be a plain array ["pkg/*"] or an object { packages: ["pkg/*"] }
289
+ let patterns = rootPkg.workspaces;
290
+ if (patterns && !Array.isArray(patterns)) patterns = patterns.packages || [];
291
+ if (!patterns || !patterns.length) return [];
292
+
293
+ const dirs = [];
294
+ for (const pattern of patterns) {
295
+ const matches = globSync(pattern, { cwd: projectPath });
296
+ for (const match of matches) {
297
+ const abs = path.join(projectPath, match);
298
+ try {
299
+ const stat = await fs.stat(abs);
300
+ if (!stat.isDirectory()) continue;
301
+ if (!(await fs.pathExists(path.join(abs, 'package.json')))) continue;
302
+ const rel = path.relative(projectPath, abs).replace(/\\/g, '/');
303
+ if (rel && rel !== '.') dirs.push(rel);
304
+ } catch { /* skip inaccessible */ }
305
+ }
306
+ }
307
+ return [...new Set(dirs)];
308
+ } catch {
309
+ return [];
310
+ }
311
+ }
312
+
313
+ async function findWorkspaceSharedConfigs(projectPath, workspacePackageDirs) {
314
+ if (!workspacePackageDirs.length) return [];
315
+ const parentDirs = [...new Set(
316
+ workspacePackageDirs.map(d => path.dirname(d)).filter(d => d !== '.')
317
+ )];
318
+ const result = [];
319
+ for (const parentDir of parentDirs) {
320
+ const absParent = path.join(projectPath, parentDir);
321
+ try {
322
+ const entries = await fs.readdir(absParent, { withFileTypes: true });
323
+ for (const entry of entries) {
324
+ if (!entry.isFile()) continue;
325
+ const base = entry.name;
326
+ if (!ROOT_CONFIG_FILES.includes(base) && !(base.startsWith('tsconfig.') && base.endsWith('.json'))) continue;
327
+ const stat = await fs.stat(path.join(absParent, base));
328
+ if (stat.size === 0) continue;
329
+ result.push(path.join(parentDir, base).replace(/\\/g, '/'));
330
+ }
331
+ } catch { /* skip inaccessible */ }
332
+ }
333
+ return result;
334
+ }
335
+
336
+ // ── Main Entry ───────────────────────────────────────────────────────────────
337
+
338
+ async function analyseProject(projectPath, hints = {}) {
339
+ const allRoots = await findServiceRoots(projectPath);
340
+
341
+ // Drop any service whose relative path contains a non-deployable directory component.
342
+ const roots = allRoots.filter(({ dir }) => {
343
+ const rel = path.relative(projectPath, dir).replace(/\\/g, '/');
344
+ const hasNonDeployable = rel.split('/').some(part => NON_DEPLOYABLE_PATH_PARTS.has(part));
345
+ return !hasNonDeployable;
346
+ });
347
+
348
+ if (roots.length === 0) {
349
+ throw new Error(
350
+ 'No supported stack found (Node.js, Python, .NET). ' +
351
+ 'Check the URL points at a folder containing source code.'
352
+ );
353
+ }
354
+
355
+ const serviceRootDirs = roots.map(r => r.dir);
356
+ const { shared: sharedDirs, staticAssets } = await findSharedDirs(projectPath, serviceRootDirs);
357
+
358
+ // Detect workspace monorepo — npm/yarn use package.json "workspaces", pnpm uses pnpm-workspace.yaml
359
+ let isWorkspace = false;
360
+ try {
361
+ const rootPkg = await fs.readJson(path.join(projectPath, 'package.json'));
362
+ if (rootPkg.workspaces) isWorkspace = true;
363
+ } catch {}
364
+ if (!isWorkspace) {
365
+ isWorkspace = await fs.pathExists(path.join(projectPath, 'pnpm-workspace.yaml'));
366
+ }
367
+
368
+ // Find ALL workspace member dirs that have a package.json in the workdir.
369
+ // These are needed by the generator so it can copy their package.json files before
370
+ // running yarn/npm/pnpm install — without them, workspace-hoisted devDeps (like
371
+ // cross-env, vite, etc.) are never installed and build scripts fail with exit 127.
372
+ const workspacePackageDirs = await findWorkspacePackageDirs(projectPath);
373
+ const workspaceSharedConfigs = await findWorkspaceSharedConfigs(projectPath, workspacePackageDirs);
374
+
375
+ const services = [];
376
+
377
+ for (const { dir, stack } of roots) {
378
+ const files = getFileList(dir);
379
+ const resolvedStack = hints.stack || stack;
380
+
381
+ let analysis;
382
+ if (resolvedStack === STACKS.NODE) analysis = await analyseNode(dir, files, hints, projectPath, isWorkspace);
383
+ else if (resolvedStack === STACKS.PYTHON) analysis = await analysePython(dir, files, hints);
384
+ else if (resolvedStack === STACKS.DOTNET) analysis = await analyseDotnet(dir, files, hints);
385
+ else continue;
386
+
387
+ // Attach location info relative to project root
388
+ analysis.serviceDir = path.relative(projectPath, dir).replace(/\\/g, '/') || '.';
389
+ analysis.serviceDirAbs = dir;
390
+ services.push(analysis);
391
+ }
392
+
393
+ if (services.length === 0) {
394
+ throw new Error('Could not analyse any detected services.');
395
+ }
396
+
397
+ // Detect root-level config files for explicit COPY in build stages
398
+ const rootConfigFiles = await findRootConfigFiles(projectPath);
399
+
400
+ // Detect required env vars from .env.example or .env.sample
401
+ const envVars = await findEnvVars(projectPath);
402
+
403
+ // Detect whitelist-style .dockerignore and surface any service dirs it blocks.
404
+ // Whitelist pattern: first non-comment line is bare `*` (ignore everything),
405
+ // then explicit `!dir/` lines re-include specific paths.
406
+ const dockerignoreBlockedDirs = await findDockerignoreBlockedServiceDirs(projectPath, services);
407
+
408
+ return { services, sharedDirs, staticAssets, projectPath, rootConfigFiles, envVars, dockerignoreBlockedDirs, workspacePackageDirs, workspaceSharedConfigs };
409
+ }
410
+
411
+ // Returns top-level service dirs that a whitelist .dockerignore would block.
412
+ // Used by the generator to add explicit `!dir/` negation entries and warn the user.
413
+ async function findDockerignoreBlockedServiceDirs(projectPath, services) {
414
+ try {
415
+ const raw = await fs.readFile(path.join(projectPath, '.dockerignore'), 'utf-8');
416
+ const lines = raw.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
417
+
418
+ // Whitelist pattern: first effective line must be bare `*`
419
+ if (lines[0] !== '*') return [];
420
+
421
+ // Collect explicitly allowed top-level dirs from `!dir/` entries
422
+ const allowed = new Set();
423
+ for (const line of lines) {
424
+ const m = line.match(/^!([^/]+)\/?$/);
425
+ if (m) allowed.add(m[1]);
426
+ }
427
+
428
+ // Find top-level dirs of each service that are NOT in the allowed set
429
+ const blocked = new Set();
430
+ for (const s of services) {
431
+ const topDir = s.serviceDir.split('/')[0];
432
+ if (topDir && topDir !== '.' && !allowed.has(topDir)) {
433
+ blocked.add(topDir);
434
+ }
435
+ }
436
+
437
+ return [...blocked];
438
+ } catch {
439
+ return [];
440
+ }
441
+ }
442
+
443
+ // ── Node.js Analysis ─────────────────────────────────────────────────────────
444
+
445
+ async function analyseNode(projectPath, files, hints, projectRootPath = null, isWorkspace = false) {
446
+ const assumptions = [];
447
+ const pkgPath = path.join(projectPath, 'package.json');
448
+ let pkg = {};
449
+ let allPackageDeps = {};
450
+ let hasScopedPackages = false;
451
+
452
+ try { pkg = await fs.readJson(pkgPath); }
453
+ catch { assumptions.push('Could not read package.json, using defaults'); }
454
+
455
+ allPackageDeps = {
456
+ ...pkg.dependencies,
457
+ ...pkg.devDependencies,
458
+ ...pkg.peerDependencies,
459
+ ...pkg.optionalDependencies,
460
+ };
461
+ hasScopedPackages = Object.keys(allPackageDeps).some(dep => dep.startsWith('@'));
462
+ if (hasScopedPackages) {
463
+ assumptions.push('Scoped packages detected - if any are private, pass an npmrc secret with docker build --secret id=npmrc,src=.npmrc');
464
+ }
465
+
466
+ // Version
467
+ let version = hints.runtimeVersion;
468
+ if (!version) {
469
+ version = pkg?.engines?.node?.replace(/[^0-9.]/g, '').split('.')[0];
470
+ if (!version) {
471
+ version = DEFAULT_VERSIONS[STACKS.NODE];
472
+ assumptions.push(`No engines.node found, defaulted to Node ${version}`);
473
+ }
474
+ }
475
+
476
+ // Package manager + lock file location
477
+ // For monorepos (workspace or not), the lock file often lives at project root, not in the package.
478
+ // Strategy: check service dir first, then fall back to project root unconditionally.
479
+ //
480
+ // IMPORTANT: only check top-level files for lockfile detection. The recursive `files` list
481
+ // contains every file in the subtree — including __tests__/fixtures — so a yarn.lock inside
482
+ // a fixture would otherwise be detected as the service's own lockfile.
483
+ const topLevelFileNames = files
484
+ .filter(f => path.dirname(f) === projectPath)
485
+ .map(f => path.basename(f));
486
+ let packageManager = hints.packageManager;
487
+ let lockFileAtRoot = false;
488
+
489
+ if (!packageManager) {
490
+ // Check service directory first (top-level only)
491
+ if (topLevelFileNames.includes('pnpm-lock.yaml')) packageManager = 'pnpm';
492
+ else if (topLevelFileNames.includes('yarn.lock')) packageManager = 'yarn';
493
+ else if (topLevelFileNames.includes('package-lock.json')) packageManager = 'npm';
494
+
495
+ // Not found in service dir — check project root.
496
+ // Covers: npm workspaces, yarn workspaces, pnpm workspaces, and any repo
497
+ // where the lock file simply lives at the top level.
498
+ if (!packageManager && projectRootPath && projectRootPath !== projectPath) {
499
+ try {
500
+ const rootEntries = await fs.readdir(projectRootPath);
501
+ if (rootEntries.includes('pnpm-lock.yaml')) { packageManager = 'pnpm'; lockFileAtRoot = true; }
502
+ else if (rootEntries.includes('yarn.lock')) { packageManager = 'yarn'; lockFileAtRoot = true; }
503
+ else if (rootEntries.includes('package-lock.json')) { packageManager = 'npm'; lockFileAtRoot = true; }
504
+ } catch {}
505
+ }
506
+
507
+ packageManager = packageManager || 'npm';
508
+ }
509
+
510
+ // Lock file
511
+ const lockFile =
512
+ packageManager === 'pnpm' ? 'pnpm-lock.yaml' :
513
+ packageManager === 'yarn' ? 'yarn.lock' :
514
+ 'package-lock.json';
515
+
516
+ // Entry point — prefer explicit hint, then main, then parse start script
517
+ let entryPoint = hints.entryPoint || pkg.main || 'index.js';
518
+ const rawStartScript = pkg.scripts?.start || '';
519
+ const nodeStartMatch = rawStartScript.match(/node\s+([\w./\-]+(?:\.js)?)/);
520
+ if (nodeStartMatch) entryPoint = nodeStartMatch[1];
521
+
522
+ // Build step
523
+ const hasBuild = !!(pkg.scripts?.build);
524
+ const buildCommand = hasBuild ? `${packageManager} run build` : null;
525
+
526
+ // Build output directory — detect framework-specific output paths
527
+ let buildOutputDir = 'dist';
528
+ let framework = null;
529
+ // Root-level dirs referenced by vite config via '../dirname' imports.
530
+ // Must be copied into build context before `vite build` runs.
531
+ const rootBuildDeps = [];
532
+ if (hasBuild) {
533
+ const buildScript = pkg.scripts.build || '';
534
+ if (buildScript.includes('react-scripts')) { buildOutputDir = 'build'; framework = 'cra'; }
535
+ else if (buildScript.includes('next build')) { buildOutputDir = '.next'; framework = 'nextjs'; }
536
+ else if (buildScript.includes('nest build')) { buildOutputDir = 'dist'; framework = 'nestjs'; }
537
+ else if (buildScript.includes('vite build')) { buildOutputDir = 'dist'; framework = 'vite'; }
538
+ else if (buildScript.includes('remix build')) { buildOutputDir = 'build'; framework = 'remix'; }
539
+ else if (buildScript.includes('astro build')) { buildOutputDir = 'dist'; framework = 'astro'; }
540
+ else if (buildScript.includes('svelte-kit build') || buildScript.includes('vite build')) { buildOutputDir = 'build'; framework = 'sveltekit'; }
541
+ }
542
+
543
+ // Also detect framework via dependencies when build script is custom
544
+ if (!framework) {
545
+ const allDeps = allPackageDeps;
546
+ if (allDeps['@nestjs/core']) framework = 'nestjs';
547
+ else if (allDeps['vite']) { buildOutputDir = 'dist'; framework = 'vite'; }
548
+ else if (allDeps['remix']) { buildOutputDir = 'build'; framework = 'remix'; }
549
+ else if (allDeps['astro']) { buildOutputDir = 'dist'; framework = 'astro'; }
550
+ else if (allDeps['@sveltejs/kit']) { buildOutputDir = 'build'; framework = 'sveltekit'; }
551
+ }
552
+
553
+ // Filesystem-based framework detection — catches cases where build script is indirect
554
+ // (e.g. yarn build:app) and dep-based detection fails.
555
+ if (!framework) {
556
+ const viteConfigs = ['vite.config.mts', 'vite.config.ts', 'vite.config.js', 'vite.config.mjs'];
557
+ for (const f of viteConfigs) {
558
+ if (await fs.pathExists(path.join(projectPath, f))) {
559
+ framework = 'vite';
560
+ buildOutputDir = 'dist'; // will be overridden below by config read
561
+ break;
562
+ }
563
+ // Fallback: check projectRootPath (monorepo vite config at root)
564
+ if (!framework && projectRootPath && projectRootPath !== projectPath) {
565
+ if (await fs.pathExists(path.join(projectRootPath, f))) {
566
+ framework = 'vite';
567
+ buildOutputDir = 'dist'; // will be overridden below by config read
568
+ break;
569
+ }
570
+ }
571
+ }
572
+ }
573
+
574
+ // Read config files to override defaults with actual configured output directories.
575
+ // Must use serviceDir (= projectPath in this function), NOT projectRootPath —
576
+ // in a monorepo these are different directories and the config lives in the service.
577
+ const serviceDir = projectPath;
578
+
579
+ if (framework === 'vite') {
580
+ const viteConfigNames = ['vite.config.mts', 'vite.config.ts', 'vite.config.js', 'vite.config.mjs'];
581
+ for (const configFile of viteConfigNames) {
582
+ try {
583
+ const configPath = path.join(serviceDir, configFile);
584
+ if (await fs.pathExists(configPath)) {
585
+ const content = await fs.readFile(configPath, 'utf-8');
586
+
587
+ // Detect '../dirname' imports — root-level dirs needed at build time
588
+ const relImportRe = /from\s+['"]\.\.\/([^/'"]+)/g;
589
+ let relMatch;
590
+ while ((relMatch = relImportRe.exec(content)) !== null) {
591
+ const dep = relMatch[1];
592
+ if (!rootBuildDeps.includes(dep)) rootBuildDeps.push(dep);
593
+ }
594
+
595
+ const val = extractQuotedConfigValue(content, 'outDir');
596
+ if (val && val.length > 0 && val.length < 40) {
597
+ buildOutputDir = val;
598
+ }
599
+ if (buildOutputDir !== 'dist') break;
600
+ }
601
+ } catch (e) { /* skip unreadable config */ }
602
+ }
603
+ // Fallback: check projectRootPath if not found in serviceDir (works for both monorepo and single-root)
604
+ if (buildOutputDir === 'dist' && projectRootPath) {
605
+ for (const configFile of viteConfigNames) {
606
+ try {
607
+ const configPath = path.join(projectRootPath, configFile);
608
+ const exists = await fs.pathExists(configPath);
609
+
610
+ if (exists) {
611
+ const content = await fs.readFile(configPath, 'utf-8');
612
+
613
+ const val = extractQuotedConfigValue(content, 'outDir');
614
+ if (val && val.length > 0 && val.length < 40) {
615
+ buildOutputDir = val;
616
+ }
617
+ if (buildOutputDir !== 'dist') break;
618
+ }
619
+ } catch (e) { /* skip unreadable config */ }
620
+ }
621
+ }
622
+ } else if (framework === 'nextjs') {
623
+ try {
624
+ const configPath = path.join(serviceDir, 'next.config.js');
625
+ if (await fs.pathExists(configPath)) {
626
+ const content = await fs.readFile(configPath, 'utf-8');
627
+ const match = content.match(/distDir\s*:\s*['"]([^'"]+)['"]/);
628
+ if (match && match[1]) buildOutputDir = match[1];
629
+ }
630
+ } catch { /* skip on error */ }
631
+ } else if (framework === 'astro') {
632
+ const astroConfigNames = ['astro.config.mjs', 'astro.config.js', 'astro.config.ts'];
633
+ for (const configFile of astroConfigNames) {
634
+ try {
635
+ const configPath = path.join(serviceDir, configFile);
636
+ if (await fs.pathExists(configPath)) {
637
+ const content = await fs.readFile(configPath, 'utf-8');
638
+ const match = content.match(/outDir\s*:\s*new\s+URL\(['"]([^'"]+)['"]/);
639
+ if (match && match[1]) { buildOutputDir = match[1]; break; }
640
+ }
641
+ } catch { /* skip on error */ }
642
+ }
643
+ } else if (framework === 'sveltekit') {
644
+ try {
645
+ const configPath = path.join(serviceDir, 'svelte.config.js');
646
+ if (await fs.pathExists(configPath)) {
647
+ const content = await fs.readFile(configPath, 'utf-8');
648
+ const match = content.match(/outDir\s*:\s*['"]([^'"]+)['"]/);
649
+ if (match && match[1]) buildOutputDir = match[1];
650
+ }
651
+ } catch { /* skip on error */ }
652
+ } else if (framework === 'remix') {
653
+ try {
654
+ const configPath = path.join(serviceDir, 'remix.config.js');
655
+ if (await fs.pathExists(configPath)) {
656
+ const content = await fs.readFile(configPath, 'utf-8');
657
+ const match = content.match(/assetsBuildDirectory\s*:\s*['"]([^'"]+)['"]/);
658
+ if (match && match[1]) buildOutputDir = match[1];
659
+ }
660
+ } catch { /* skip on error */ }
661
+ }
662
+
663
+ // Port
664
+ let port = hints.port;
665
+ if (!port) {
666
+ const portMatch = rawStartScript.match(/PORT[=\s]+(\d+)/);
667
+ port = portMatch ? parseInt(portMatch[1]) : DEFAULT_PORTS[STACKS.NODE];
668
+ if (!portMatch) assumptions.push(`Port not found in scripts, defaulted to ${port}`);
669
+ }
670
+
671
+ // Docker CMD — detect whether runtime needs 'node <file>' or 'npm run <script>'
672
+ // Direct node invocation is preferred (better signal handling, no npm overhead)
673
+ // but many production scripts use npm/yarn run for env setup, cross-env, etc.
674
+ let startCmd;
675
+ const npmRunMatch = rawStartScript.match(/^(?:npm\s+run|yarn\s+run|pnpm\s+run)\s+(\S+)/);
676
+ if (hints.entryPoint) {
677
+ startCmd = ['node', hints.entryPoint];
678
+ } else if (nodeStartMatch) {
679
+ startCmd = ['node', nodeStartMatch[1]];
680
+ } else if (npmRunMatch) {
681
+ const scriptName = npmRunMatch[1];
682
+ startCmd = packageManager === 'yarn' ? ['yarn', scriptName]
683
+ : packageManager === 'pnpm' ? ['pnpm', 'run', scriptName]
684
+ : ['npm', 'run', scriptName];
685
+ } else if (rawStartScript) {
686
+ // Complex script (cross-env, concurrently, etc.) — wrap with package manager
687
+ startCmd = packageManager === 'yarn' ? ['yarn', 'start']
688
+ : packageManager === 'pnpm' ? ['pnpm', 'run', 'start']
689
+ : ['npm', 'run', 'start'];
690
+ } else {
691
+ startCmd = ['node', entryPoint];
692
+ }
693
+
694
+ // NestJS: runtime must use compiled dist, not the dev CLI (nest start)
695
+ if (framework === 'nestjs' && hasBuild && !hints.entryPoint) {
696
+ const prodScript = pkg.scripts?.['start:prod'] || '';
697
+ const prodNodeMatch = prodScript.match(/node\s+([\w./\-]+(?:\.js)?)/);
698
+ startCmd = prodNodeMatch ? ['node', prodNodeMatch[1]] : ['node', 'dist/main.js'];
699
+ }
700
+
701
+ // Install command
702
+ const installCmd =
703
+ packageManager === 'pnpm' ? 'pnpm install --frozen-lockfile' :
704
+ packageManager === 'yarn' ? 'yarn install --frozen-lockfile' :
705
+ 'npm ci';
706
+
707
+ // Role
708
+ const role = await detectRole(projectPath, pkg);
709
+
710
+ // Detect sibling directories referenced via require('../xxx') in the entry point.
711
+ // These are root-level dirs that sit alongside the service dir and must be COPYed
712
+ // into the image so cross-directory requires resolve at runtime.
713
+ const siblingDeps = await detectSiblingDeps(projectPath, projectRootPath, entryPoint);
714
+
715
+ return {
716
+ stack: STACKS.NODE,
717
+ role,
718
+ version,
719
+ packageManager,
720
+ entryPoint,
721
+ hasBuild,
722
+ buildCommand,
723
+ framework, // 'cra' | 'nextjs' | null
724
+ buildOutputDir, // 'dist' | 'build' | '.next' — where npm run build writes output
725
+ startCmd, // array form for Docker CMD, e.g. ['node','dist/index.js'] or ['npm','run','start:prod']
726
+ port,
727
+ installCmd,
728
+ lockFile,
729
+ lockFileAtRoot, // true when lock file lives at project root (monorepo/workspace pattern)
730
+ isWorkspace, // true when formal workspace (npm/yarn/pnpm) — used for scoped install cmds
731
+ rootBuildDeps, // root-level dirs imported by vite config via '../dirname' — must be copied before build
732
+ hasScopedPackages,
733
+ siblingDeps, // root-level dirs referenced via require('../xxx') — must be COPYed into runtime image
734
+ assumptions,
735
+ };
736
+ }
737
+
738
+ // ── Python Analysis ──────────────────────────────────────────────────────────
739
+
740
+ function modulePathFromPythonFile(projectPath, filePath) {
741
+ const rel = path.relative(projectPath, filePath).replace(/\\/g, '/').replace(/\.py$/, '');
742
+ const parts = rel.split('/').filter(Boolean);
743
+ if (parts[parts.length - 1] === '__init__') parts.pop();
744
+ return parts.join('.');
745
+ }
746
+
747
+ async function readPythonDependencyText(projectPath, fileNames) {
748
+ const candidates = ['requirements.txt', 'pyproject.toml', 'Pipfile', 'setup.py'];
749
+ const chunks = [];
750
+
751
+ for (const fileName of candidates) {
752
+ if (!fileNames.includes(fileName)) continue;
753
+ const content = await fs.readFile(path.join(projectPath, fileName), 'utf-8').catch(() => '');
754
+ if (content) chunks.push(content);
755
+ }
756
+
757
+ return chunks.join('\n').toLowerCase();
758
+ }
759
+
760
+ function detectPythonNativeDeps(dependencyText) {
761
+ const nativePackages = [
762
+ 'psycopg2', 'psycopg2-binary', 'numpy', 'pillow', 'lxml',
763
+ 'cryptography', 'mysqlclient', 'pycurl', 'scipy', 'pandas',
764
+ 'orjson', 'uvloop', 'grpcio', 'pydantic-core', 'aiohttp',
765
+ 'greenlet', 'gevent', 'brotli', 'brotlicffi', 'shapely',
766
+ 'regex', 'cffi', 'tokenizers', 'transformers',
767
+ ];
768
+ const nativeBuildSystems = ['maturin', 'setuptools-rust', 'cython'];
769
+ const nativeDeps = nativePackages.filter(pkg => {
770
+ const escaped = pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
771
+ return new RegExp(`(^|[^a-z0-9_.-])${escaped}([^a-z0-9_.-]|$)`, 'i').test(dependencyText);
772
+ });
773
+ for (const pkg of nativeBuildSystems) {
774
+ const escaped = pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
775
+ if (new RegExp(`(^|[^a-z0-9_.-])${escaped}([^a-z0-9_.-]|$)`, 'i').test(dependencyText)) {
776
+ nativeDeps.push(pkg);
777
+ }
778
+ }
779
+ return nativeDeps;
780
+ }
781
+
782
+ function detectRequirementsHashes(dependencyText) {
783
+ return /--hash=sha256:/i.test(dependencyText);
784
+ }
785
+
786
+ async function findDjangoDirs(projectPath, files) {
787
+ const dirs = new Set();
788
+ for (const filePath of files) {
789
+ if (!filePath.endsWith('.py')) continue;
790
+ const rel = path.relative(projectPath, filePath).replace(/\\/g, '/');
791
+ const parts = rel.split('/');
792
+ if (parts.length < 2) continue;
793
+ const fileName = parts[parts.length - 1];
794
+ if (!['apps.py', 'models.py', 'views.py', 'wsgi.py', 'asgi.py', 'settings.py'].includes(fileName)) continue;
795
+ const topDir = parts[0];
796
+ if (topDir && !topDir.startsWith('.') && !SKIP_DIRS.has(topDir)) dirs.add(topDir);
797
+ }
798
+ return [...dirs].sort();
799
+ }
800
+
801
+ async function findDotnetSourceDirs(projectPath) {
802
+ const common = [
803
+ 'Controllers', 'Services', 'Models', 'Pages', 'Views', 'Repositories',
804
+ 'Data', 'Middleware', 'Handlers', 'Features', 'Dtos', 'DTOs',
805
+ ];
806
+ const found = [];
807
+ for (const dir of common) {
808
+ try {
809
+ const abs = path.join(projectPath, dir);
810
+ const stat = await fs.stat(abs);
811
+ if (stat.isDirectory()) found.push(dir);
812
+ } catch { /* absent */ }
813
+ }
814
+ return found;
815
+ }
816
+
817
+ async function readPythonFile(filePath) {
818
+ return fs.readFile(filePath, 'utf-8').catch(() => '');
819
+ }
820
+
821
+ function sortPythonAppCandidates(projectPath, files) {
822
+ const rank = filePath => {
823
+ const rel = path.relative(projectPath, filePath).replace(/\\/g, '/');
824
+ const base = path.basename(filePath);
825
+ if (base === 'wsgi.py') return 0;
826
+ if (base === 'asgi.py') return 1;
827
+ if (base === 'app.py') return 2;
828
+ if (base === 'main.py') return 3;
829
+ if (base === 'application.py') return 4;
830
+ if (base === '__init__.py') return 5;
831
+ return rel.split('/').length + 10;
832
+ };
833
+
834
+ return files
835
+ .filter(f => f.endsWith('.py'))
836
+ .sort((a, b) => rank(a) - rank(b));
837
+ }
838
+
839
+ async function detectDjangoTarget(projectPath, files) {
840
+ const managePath = path.join(projectPath, 'manage.py');
841
+ if (await fs.pathExists(managePath)) {
842
+ const manageContent = await readPythonFile(managePath);
843
+ const settingsMatch = manageContent.match(/DJANGO_SETTINGS_MODULE['"]\s*,\s*['"]([^'"]+)['"]/);
844
+ if (settingsMatch?.[1]) {
845
+ const wsgiModule = settingsMatch[1].replace(/\.settings(?:\.[\w.]+)?$/, '.wsgi');
846
+ return `${wsgiModule}:application`;
847
+ }
848
+ }
849
+
850
+ for (const filePath of sortPythonAppCandidates(projectPath, files)) {
851
+ if (path.basename(filePath) !== 'wsgi.py') continue;
852
+ const content = await readPythonFile(filePath);
853
+ if (!content.includes('get_wsgi_application')) continue;
854
+ const modulePath = modulePathFromPythonFile(projectPath, filePath);
855
+ if (modulePath) return `${modulePath}:application`;
856
+ }
857
+
858
+ return null;
859
+ }
860
+
861
+ async function detectFlaskTarget(projectPath, files) {
862
+ for (const filePath of sortPythonAppCandidates(projectPath, files)) {
863
+ const content = await readPythonFile(filePath);
864
+ if (!content.includes('Flask')) continue;
865
+ const modulePath = modulePathFromPythonFile(projectPath, filePath);
866
+ if (!modulePath) continue;
867
+
868
+ const variableMatch = content.match(/^([A-Za-z_]\w*)\s*=\s*Flask\s*\(/m);
869
+ if (variableMatch?.[1]) return `${modulePath}:${variableMatch[1]}`;
870
+
871
+ const factoryMatch = content.match(/^def\s+((?:create|make)_app)\s*\(/m);
872
+ if (factoryMatch?.[1]) return `${modulePath}:${factoryMatch[1]}()`;
873
+ }
874
+
875
+ return null;
876
+ }
877
+
878
+ async function detectFastApiTarget(projectPath, files) {
879
+ for (const filePath of sortPythonAppCandidates(projectPath, files)) {
880
+ const content = await readPythonFile(filePath);
881
+ if (!content.includes('FastAPI')) continue;
882
+ const modulePath = modulePathFromPythonFile(projectPath, filePath);
883
+ if (!modulePath) continue;
884
+
885
+ const variableMatch = content.match(/^([A-Za-z_]\w*)\s*=\s*FastAPI\s*\(/m);
886
+ if (variableMatch?.[1]) return `${modulePath}:${variableMatch[1]}`;
887
+ }
888
+
889
+ return null;
890
+ }
891
+
892
+ async function detectPythonFramework(projectPath, files, dependencyText) {
893
+ if (/\bdjango\b/.test(dependencyText)) return 'django';
894
+ if (/\bfastapi\b/.test(dependencyText)) return 'fastapi';
895
+ if (/\bflask\b/.test(dependencyText)) return 'flask';
896
+
897
+ if (await fs.pathExists(path.join(projectPath, 'manage.py'))) return 'django';
898
+
899
+ for (const filePath of sortPythonAppCandidates(projectPath, files)) {
900
+ const content = await readPythonFile(filePath);
901
+ if (content.includes('FastAPI(')) return 'fastapi';
902
+ if (content.includes('Flask(')) return 'flask';
903
+ if (content.includes('DJANGO_SETTINGS_MODULE') || content.includes('get_wsgi_application')) return 'django';
904
+ }
905
+
906
+ return null;
907
+ }
908
+
909
+ async function analysePython(projectPath, files, hints) {
910
+ const assumptions = [];
911
+ const fileNames = files.map(f => path.basename(f));
912
+
913
+ let version = hints.runtimeVersion;
914
+ if (!version) {
915
+ const pvPath = path.join(projectPath, '.python-version');
916
+ if (await fs.pathExists(pvPath)) {
917
+ version = (await fs.readFile(pvPath, 'utf-8')).trim().split('.').slice(0, 2).join('.');
918
+ }
919
+ if (!version && fileNames.includes('pyproject.toml')) {
920
+ const content = await fs.readFile(path.join(projectPath, 'pyproject.toml'), 'utf-8');
921
+ const match = content.match(/python\s*=\s*["'^>=~]+([0-9.]+)/);
922
+ if (match) version = match[1].split('.').slice(0, 2).join('.');
923
+ }
924
+ if (!version) {
925
+ version = DEFAULT_VERSIONS[STACKS.PYTHON];
926
+ assumptions.push(`Python version not found, defaulted to ${version}`);
927
+ }
928
+ }
929
+
930
+ let packageManager = hints.packageManager || 'pip';
931
+ let requirementsFile = 'requirements.txt';
932
+ let installCmd = 'pip install --no-cache-dir -r requirements.txt';
933
+
934
+ if (fileNames.includes('Pipfile')) {
935
+ packageManager = 'pipenv';
936
+ installCmd = 'pipenv install --system --deploy';
937
+ } else if (fileNames.includes('pyproject.toml')) {
938
+ const content = await fs.readFile(path.join(projectPath, 'pyproject.toml'), 'utf-8');
939
+ if (content.includes('[tool.poetry]')) {
940
+ packageManager = 'poetry';
941
+ installCmd = 'pip install poetry && poetry install --no-dev';
942
+ }
943
+ }
944
+
945
+ const dependencyText = await readPythonDependencyText(projectPath, fileNames);
946
+ const nativeDeps = detectPythonNativeDeps(dependencyText);
947
+ const hasNativeDeps = nativeDeps.length > 0;
948
+ const requirementsHasHashes = detectRequirementsHashes(dependencyText);
949
+ let framework = await detectPythonFramework(projectPath, files, dependencyText);
950
+ const djangoAppDirs = framework === 'django' ? await findDjangoDirs(projectPath, files) : [];
951
+ let entryPoint = hints.entryPoint;
952
+
953
+ if (!entryPoint) {
954
+ if (framework === 'django' && fileNames.includes('manage.py')) entryPoint = 'manage.py';
955
+ else if (fileNames.includes('main.py')) entryPoint = 'main.py';
956
+ else if (fileNames.includes('app.py')) entryPoint = 'app.py';
957
+ else {
958
+ entryPoint = 'main.py';
959
+ assumptions.push('Entry point not found — defaulted to main.py');
960
+ }
961
+ }
962
+
963
+ const port = hints.port || DEFAULT_PORTS[STACKS.PYTHON];
964
+
965
+ let startCmd;
966
+ let appTarget = null;
967
+ if (framework === 'django') {
968
+ appTarget = await detectDjangoTarget(projectPath, files);
969
+ if (!appTarget) {
970
+ appTarget = 'app.wsgi:application';
971
+ assumptions.push('Django project module not detected - using app.wsgi:application; update this to your project package if needed');
972
+ }
973
+ startCmd = ['gunicorn', appTarget, '--bind', `0.0.0.0:${port}`];
974
+ } else if (framework === 'fastapi') {
975
+ appTarget = await detectFastApiTarget(projectPath, files);
976
+ if (!appTarget) {
977
+ appTarget = 'main:app';
978
+ assumptions.push('FastAPI app target not detected - using main:app; update this if your app uses a different module or variable');
979
+ }
980
+ startCmd = ['uvicorn', appTarget, '--host', '0.0.0.0', '--port', String(port), '--workers', '4'];
981
+ } else if (framework === 'flask') {
982
+ appTarget = await detectFlaskTarget(projectPath, files);
983
+ if (!appTarget) {
984
+ const moduleName = path.basename(entryPoint, path.extname(entryPoint));
985
+ appTarget = `${moduleName}:app`;
986
+ assumptions.push('Flask app target not detected - using app variable on the entry point; update the Gunicorn target if needed');
987
+ }
988
+ startCmd = ['gunicorn', appTarget, '--bind', `0.0.0.0:${port}`];
989
+ } else {
990
+ startCmd = ['python', entryPoint];
991
+ }
992
+
993
+ const needsGunicornInstall =
994
+ ['django', 'flask'].includes(framework) &&
995
+ !dependencyText.includes('gunicorn');
996
+
997
+ return {
998
+ stack: STACKS.PYTHON,
999
+ role: 'backend',
1000
+ version,
1001
+ packageManager,
1002
+ requirementsFile,
1003
+ installCmd,
1004
+ entryPoint,
1005
+ appTarget,
1006
+ startCmd,
1007
+ framework,
1008
+ needsGunicornInstall,
1009
+ hasNativeDeps,
1010
+ nativeDeps,
1011
+ requirementsHasHashes,
1012
+ djangoAppDirs,
1013
+ port,
1014
+ hasBuild: hasNativeDeps,
1015
+ assumptions,
1016
+ };
1017
+ }
1018
+
1019
+ // ── .NET Analysis ────────────────────────────────────────────────────────────
1020
+
1021
+ async function analyseDotnet(projectPath, files, hints) {
1022
+ const assumptions = [];
1023
+
1024
+ const csprojFiles = files.filter(f => f.endsWith('.csproj'));
1025
+ const csprojPath = csprojFiles[0];
1026
+ let csprojContent = '';
1027
+
1028
+ try { csprojContent = await fs.readFile(csprojPath, 'utf-8'); }
1029
+ catch { assumptions.push('Could not read .csproj, using defaults'); }
1030
+
1031
+ let version = hints.runtimeVersion;
1032
+ if (!version) {
1033
+ const match = csprojContent.match(/<TargetFramework>net([0-9.]+)<\/TargetFramework>/);
1034
+ if (match) { version = match[1]; }
1035
+ else {
1036
+ version = DEFAULT_VERSIONS[STACKS.DOTNET];
1037
+ assumptions.push(`TargetFramework not found, defaulted to .NET ${version}`);
1038
+ }
1039
+ }
1040
+
1041
+ const projectName = path.basename(csprojPath, '.csproj');
1042
+ const isWebApp = csprojContent.includes('Microsoft.NET.Sdk.Web') || csprojContent.includes('Aspnet');
1043
+ const port = hints.port || DEFAULT_PORTS[STACKS.DOTNET];
1044
+ const dotnetSourceDirs = await findDotnetSourceDirs(projectPath);
1045
+
1046
+ return {
1047
+ stack: STACKS.DOTNET,
1048
+ role: 'backend',
1049
+ version,
1050
+ projectName,
1051
+ csprojPath: path.relative(projectPath, csprojPath),
1052
+ isWebApp,
1053
+ dotnetSourceDirs,
1054
+ port,
1055
+ hasBuild: true,
1056
+ assumptions,
1057
+ };
1058
+ }
1059
+
1060
+ // ── File List Helper ─────────────────────────────────────────────────────────
1061
+
1062
+ const { globSync } = require('glob');
1063
+
1064
+ // Scans common entry point files in a service directory for require('../xxx') and
1065
+ // import ... from '../xxx' patterns. Returns a sorted list of unique top-level
1066
+ // sibling directory names that exist at the project root — these must be COPYed
1067
+ // into the image so that cross-directory requires resolve at runtime.
1068
+ async function detectSiblingDeps(serviceDir, projectRootPath, entryPoint) {
1069
+ if (!projectRootPath) return [];
1070
+ const rel = path.relative(projectRootPath, serviceDir).replace(/\\/g, '/');
1071
+ // Only handle services exactly one level deep — deeper nesting has more complex relative paths
1072
+ if (!rel || rel === '.' || rel.includes('/')) return [];
1073
+
1074
+ const candidateFiles = [...new Set([
1075
+ path.join(serviceDir, entryPoint || 'index.js'),
1076
+ path.join(serviceDir, 'server.js'),
1077
+ path.join(serviceDir, 'server.ts'),
1078
+ path.join(serviceDir, 'app.js'),
1079
+ path.join(serviceDir, 'index.js'),
1080
+ path.join(serviceDir, 'main.js'),
1081
+ ])];
1082
+
1083
+ const siblingDirs = new Set();
1084
+ // Matches: require('../dirname') or require('../dirname/anything')
1085
+ const requireRe = /require\(['"]\.\.\/([^/'"]+)/g;
1086
+ // Matches: import ... from '../dirname' or import ... from '../dirname/anything'
1087
+ const importRe = /from\s+['"]\.\.\/([^/'"]+)/g;
1088
+
1089
+ for (const filePath of candidateFiles) {
1090
+ const content = await fs.readFile(filePath, 'utf-8').catch(() => null);
1091
+ if (!content) continue;
1092
+ for (const re of [requireRe, importRe]) {
1093
+ re.lastIndex = 0;
1094
+ let match;
1095
+ while ((match = re.exec(content)) !== null) {
1096
+ siblingDirs.add(match[1]);
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ // Filter to dirs that actually exist at the project root
1102
+ const result = [];
1103
+ for (const dir of siblingDirs) {
1104
+ try {
1105
+ const stat = await fs.stat(path.join(projectRootPath, dir));
1106
+ if (stat.isDirectory()) result.push(dir);
1107
+ } catch { /* doesn't exist or not a dir — skip */ }
1108
+ }
1109
+ return result.sort();
1110
+ }
1111
+
1112
+ function getFileList(projectPath) {
1113
+ return globSync('**/*', {
1114
+ cwd: projectPath,
1115
+ nodir: false,
1116
+ absolute: true,
1117
+ ignore: [
1118
+ '**/node_modules/**', '**/.git/**', '**/bin/**', '**/obj/**',
1119
+ '**/__tests__/**', '**/__mocks__/**', '**/test/**', '**/tests/**',
1120
+ '**/fixtures/**', '**/spec/**', '**/specs/**',
1121
+ '**/examples/**', '**/example/**', '**/samples/**', '**/sample/**',
1122
+ '**/demos/**', '**/demo/**', '**/docs/**', '**/doc/**',
1123
+ '**/dev-docs/**', '**/documentation/**', '**/storybook-static/**',
1124
+ ],
1125
+ });
1126
+ }
1127
+
1128
+ module.exports = { analyseProject, findRootConfigFiles, analyseNode };