Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
372
lib/Peach/about/internal/file/BuildService.dart
Normal file
372
lib/Peach/about/internal/file/BuildService.dart
Normal file
@@ -0,0 +1,372 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user