import 'dart:async'; import 'dart:convert'; import 'dart:io'; import '../application/ServiceConfig.dart'; import '../../../comms/Comms.dart' show LogLine, LogLevel; import '../application/StepTracker.dart'; class BuildResult { final bool ok; final String message; final File? jar; // jar services final Directory? dir; // web apps (static output directory) BuildResult(this.ok, this.message, {this.jar, this.dir}); } /// Builds a jar from a local Maven/Gradle project (desktop only) and locates /// the produced artifact so it can be deployed. class BuildService { final _logController = StreamController.broadcast(); Stream get logs => _logController.stream; final steps = StepTracker(); Stream> get stepStream => steps.stream; Process? _proc; void _log(LogLevel level, String text) { for (final line in const LineSplitter().convert(text)) { if (line.trim().isEmpty && level == LogLevel.remote) continue; _logController.add(LogLine(level, line)); } } /// True on desktop, where local builds can run. static bool get supported => Platform.isWindows || Platform.isMacOS || Platform.isLinux; Future build(ServiceConfig svc) async { steps.reset([ ('detect', 'Detect project'), ('compile', 'Compile'), ('locate', 'Locate jar'), ]); if (!supported) { steps.failRemaining(); return BuildResult(false, 'Local builds are only available on desktop.'); } final dir = svc.localProjectPath.trim(); if (dir.isEmpty) { steps.failRemaining(); return BuildResult(false, 'No project path set.'); } if (!Directory(dir).existsSync()) { steps.failRemaining(); return BuildResult(false, 'Project path does not exist: $dir'); } steps.active('detect'); final (exec, args, tool) = _resolveCommand(svc, dir); if (exec == null) { steps.failRemaining(); return BuildResult( false, 'Could not detect a Maven (pom.xml) or Gradle (build.gradle) ' 'project in $dir. Set a custom build command in the service.', ); } steps.done('detect'); steps.active('compile'); _log(LogLevel.step, 'Building $tool project in $dir …'); _log(LogLevel.info, '\$ $exec ${args.join(' ')}'); try { final proc = await Process.start( exec, args, workingDirectory: dir, runInShell: true, ); _proc = proc; proc.stdout .transform(utf8.decoder) .listen((d) => _log(LogLevel.remote, d.trimRight())); proc.stderr .transform(utf8.decoder) .listen((d) => _log(LogLevel.remote, d.trimRight())); final code = await proc.exitCode; _proc = null; if (code != 0) { steps.failRemaining(); return BuildResult(false, 'Build failed (exit code $code).'); } _log(LogLevel.success, 'Build finished ✓'); steps.done('compile'); } catch (e) { _proc = null; steps.failRemaining(); return BuildResult(false, 'Could not run build: $e'); } // Locate the produced jar. steps.active('locate'); _log(LogLevel.step, 'Locating built jar …'); final jar = _findJar(svc, dir, tool); if (jar == null) { steps.failRemaining(); return BuildResult( false, 'Build succeeded but no jar was found. Set a jar pattern.', ); } steps.done('locate'); _log(LogLevel.success, 'Found jar: ${jar.path}'); return BuildResult(true, 'Built ${_basename(jar.path)}', jar: jar); } /// Builds a web app (Angular/React/Flutter web) and returns the directory of /// static files to publish. For [WebFramework.staticFiles] no build runs. Future buildWeb(ServiceConfig svc) async { steps.reset([ ('compile', 'Build ${svc.framework.label}'), ('locate', 'Locate output'), ]); if (!supported) { steps.failRemaining(); return BuildResult(false, 'Local builds are only available on desktop.'); } final dir = svc.localProjectPath.trim(); if (dir.isEmpty) { steps.failRemaining(); return BuildResult(false, 'No project path set.'); } if (!Directory(dir).existsSync()) { steps.failRemaining(); return BuildResult(false, 'Project path does not exist: $dir'); } final command = svc.buildCommand.trim().isNotEmpty ? svc.buildCommand.trim() : svc.framework.defaultBuild; steps.active('compile'); if (command.isNotEmpty) { _log(LogLevel.step, 'Building ${svc.framework.label} in $dir …'); _log(LogLevel.info, '\$ $command'); final (exec, args) = Platform.isWindows ? ('cmd', ['/c', command]) : ('/bin/sh', ['-c', command]); try { final proc = await Process.start( exec, args, workingDirectory: dir, runInShell: true, ); _proc = proc; proc.stdout .transform(utf8.decoder) .listen((d) => _log(LogLevel.remote, d.trimRight())); proc.stderr .transform(utf8.decoder) .listen((d) => _log(LogLevel.remote, d.trimRight())); final code = await proc.exitCode; _proc = null; if (code != 0) { steps.failRemaining(); return BuildResult(false, 'Build failed (exit $code).'); } _log(LogLevel.success, 'Build finished ✓'); } catch (e) { _proc = null; steps.failRemaining(); return BuildResult(false, 'Could not run build: $e'); } } else { _log(LogLevel.info, 'Static files — no build step.'); } steps.done('compile'); steps.active('locate'); _log(LogLevel.step, 'Locating web output …'); final out = _findWebOutput(svc, dir); if (out == null) { steps.failRemaining(); return BuildResult( false, 'Could not find the built site (an index.html). ' 'Set the output directory in the service.', ); } steps.done('locate'); _log(LogLevel.success, 'Publishing from: ${out.path}'); return BuildResult(true, 'Built ${svc.framework.label}', dir: out); } Directory? _findWebOutput(ServiceConfig svc, String dir) { final sub = svc.outputSubdir.trim(); if (sub.isNotEmpty) { final d = Directory( sub.startsWith('/') || sub.contains(':') ? sub : '$dir/$sub', ); if (!d.existsSync()) return null; // If the pointed-at folder holds index.html use it; otherwise drill into // it (e.g. user set `dist` but Angular nests dist//browser). return _withIndex(d) ?? _findDistWithIndex(d.path) ?? d; } switch (svc.framework) { case WebFramework.flutterWeb: return _withIndex(Directory('$dir/build/web')); case WebFramework.angular: return _findDistWithIndex('$dir/dist'); case WebFramework.react: return _withIndex(Directory('$dir/build')) ?? _withIndex(Directory('$dir/dist')); case WebFramework.staticFiles: return _withIndex(Directory(dir)) ?? Directory(dir); } } /// Returns [d] if it (or a nested `browser/` dir) contains index.html. Directory? _withIndex(Directory d) { if (!d.existsSync()) return null; if (File('${d.path}/index.html').existsSync()) return d; final browser = Directory('${d.path}/browser'); // Angular 17+ if (File('${browser.path}/index.html').existsSync()) return browser; return null; } /// Angular puts output under `dist//` — find the one with index.html. Directory? _findDistWithIndex(String distPath) { final dist = Directory(distPath); if (!dist.existsSync()) return null; final direct = _withIndex(dist); if (direct != null) return direct; for (final entry in dist.listSync().whereType()) { final found = _withIndex(entry); if (found != null) return found; } return null; } void cancel() { _proc?.kill(); _proc = null; } void dispose() { cancel(); _logController.close(); steps.dispose(); } // ---- command resolution --------------------------------------------------- (String?, List, String) _resolveCommand( ServiceConfig svc, String dir, ) { final custom = svc.buildCommand.trim(); if (custom.isNotEmpty) { if (Platform.isWindows) { return ('cmd', ['/c', custom], 'custom'); } return ('/bin/sh', ['-c', custom], 'custom'); } final hasPom = File('$dir/pom.xml').existsSync(); final hasGradle = File('$dir/build.gradle').existsSync() || File('$dir/build.gradle.kts').existsSync(); if (hasPom) { final wrapper = _wrapper(dir, 'mvnw', 'mvnw.cmd'); final exec = wrapper ?? 'mvn'; return (exec, ['-B', '-DskipTests', 'package'], 'Maven'); } if (hasGradle) { final wrapper = _wrapper(dir, 'gradlew', 'gradlew.bat'); final exec = wrapper ?? 'gradle'; return (exec, ['clean', 'build', '-x', 'test'], 'Gradle'); } return (null, [], ''); } String? _wrapper(String dir, String unixName, String winName) { if (Platform.isWindows) { final w = File('$dir/$winName'); if (w.existsSync()) return w.path; } else { final w = File('$dir/$unixName'); if (w.existsSync()) return w.path; } return null; } // ---- jar location --------------------------------------------------------- File? _findJar(ServiceConfig svc, String dir, String tool) { final pattern = svc.jarPattern.trim(); if (pattern.isNotEmpty) { final full = pattern.startsWith('/') || pattern.contains(':') ? pattern : '$dir/$pattern'; if (!full.contains('*')) { final f = File(full); return f.existsSync() ? f : null; } final slash = full.lastIndexOf('/'); final searchDir = slash >= 0 ? full.substring(0, slash) : dir; final glob = slash >= 0 ? full.substring(slash + 1) : full; return _pickJar(searchDir, glob: glob); } // Auto: search the tool's default output dir. For a custom command the // tool is unknown, so try both common locations. final candidates = switch (tool) { 'Gradle' => ['$dir/build/libs'], 'Maven' => ['$dir/target'], _ => ['$dir/build/libs', '$dir/target'], }; for (final c in candidates) { final jar = _pickJar(c); if (jar != null) return jar; } return null; } File? _pickJar(String searchDir, {String glob = '*.jar'}) { final d = Directory(searchDir); if (!d.existsSync()) return null; final re = _globToRegExp(glob); var jars = d .listSync() .whereType() .where((f) => f.path.toLowerCase().endsWith('.jar')) .where((f) => re.hasMatch(_basename(f.path))) .toList(); if (jars.isEmpty) return null; bool isArtifact(File f) { final n = _basename(f.path).toLowerCase(); return !n.contains('-sources') && !n.contains('-javadoc') && !n.startsWith('original-') && !n.endsWith('-plain.jar'); } final preferred = jars.where(isArtifact).toList(); final pool = preferred.isNotEmpty ? preferred : jars; pool.sort((a, b) => b.statSync().modified.compareTo(a.statSync().modified)); return pool.first; } RegExp _globToRegExp(String glob) { final buf = StringBuffer('^'); for (final ch in glob.split('')) { if (ch == '*') { buf.write('.*'); } else if ('.[]{}()+-^\$|?\\'.contains(ch)) { buf.write('\\$ch'); } else { buf.write(ch); } } buf.write(r'$'); return RegExp(buf.toString(), caseSensitive: false); } static String _basename(String path) { final i = path.replaceAll('\\', '/').lastIndexOf('/'); return i >= 0 ? path.substring(i + 1) : path; } }