373 lines
12 KiB
Dart
373 lines
12 KiB
Dart
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<LogLine>.broadcast();
|
|
Stream<LogLine> get logs => _logController.stream;
|
|
final steps = StepTracker();
|
|
Stream<List<DeployStep>> 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<BuildResult> 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<BuildResult> 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/<proj>/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/<project>/` — 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<Directory>()) {
|
|
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>, 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>[], '');
|
|
}
|
|
|
|
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<File>()
|
|
.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;
|
|
}
|
|
}
|