Add app source and tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
alvocool
2026-10-11 10:15:30 +03:00
parent 7e246255c2
commit 38881d1fe1
135 changed files with 21057 additions and 0 deletions

View 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;
}
}