|
| 1 | +import 'dart:convert'; |
| 2 | +import 'dart:ffi'; |
| 3 | +import 'dart:io' show ProcessException, stderr, stdout; |
| 4 | + |
| 5 | +import 'package:archive/archive_io.dart'; |
| 6 | +import 'package:args/args.dart'; |
| 7 | +import 'package:celest_cli/src/context.dart'; |
| 8 | +import 'package:celest_cli/src/utils/error.dart'; |
| 9 | +import 'package:celest_cli/src/version.dart'; |
| 10 | +import 'package:file/file.dart'; |
| 11 | +import 'package:mustache_template/mustache.dart'; |
| 12 | +import 'package:path/path.dart' as p; |
| 13 | + |
| 14 | +/// The directory containing this script and build assets. |
| 15 | +final Directory toolDir = fileSystem.file(platform.script).parent; |
| 16 | + |
| 17 | +/// The directory with the built CLI. |
| 18 | +final String buildPath = platform.environment['BUILD_DIR'] ?? 'build'; |
| 19 | +final Directory buildDir = fileSystem.directory( |
| 20 | + platform.script.resolve('../$buildPath'), |
| 21 | +); |
| 22 | + |
| 23 | +/// The directory to use for bundled files. |
| 24 | +final Directory outputsDir = toolDir.parent.childDirectory('releases') |
| 25 | + ..createSync(); |
| 26 | + |
| 27 | +/// The directory to use for temporary (non-bundled) files. |
| 28 | +final Directory tempDir = fileSystem.systemTempDirectory.createTempSync( |
| 29 | + 'celest_build_', |
| 30 | +); |
| 31 | + |
| 32 | +/// The current ABI which identifies the OS and architecture. |
| 33 | +final Abi osArch = Abi.current(); |
| 34 | +final String hostOs = switch (osArch) { |
| 35 | + Abi.linuxArm64 || Abi.linuxX64 => 'linux', |
| 36 | + _ => throw UnsupportedError('Unsupported ABI: $osArch'), |
| 37 | +}; |
| 38 | +final String hostArch = switch (osArch) { |
| 39 | + Abi.linuxArm64 => 'arm64', |
| 40 | + Abi.linuxX64 => 'x64', |
| 41 | + _ => throw UnsupportedError('Unsupported ABI: $osArch'), |
| 42 | +}; |
| 43 | + |
| 44 | +/// The current version of the CLI. |
| 45 | +final String version = packageVersion; |
| 46 | + |
| 47 | +/// The current SHA of the branch being built. |
| 48 | +final String? currentSha = platform.environment.containsKey('CI') |
| 49 | + ? (processManager.runSync( |
| 50 | + // <String>['git', 'log', '-1', '--format=format:%H'], ? |
| 51 | + <String>['git', 'rev-parse', 'HEAD'], |
| 52 | + stdoutEncoding: utf8, |
| 53 | + ).stdout as String) |
| 54 | + .trim() |
| 55 | + : null; |
| 56 | + |
| 57 | +/// Whether we're running in CI. |
| 58 | +final isCI = platform.environment['CI'] == 'true'; |
| 59 | + |
| 60 | +/// Builds and bundles the CLI for the current platform. |
| 61 | +/// |
| 62 | +/// This script is used by the GitHub workflow `apps_cli_release.yaml` to create |
| 63 | +/// a zip of the CLI and its dependencies for the current platform. |
| 64 | +Future<void> main(List<String> args) async { |
| 65 | + final argParser = ArgParser() |
| 66 | + ..addOption('target-os', allowed: ['linux'], defaultsTo: hostOs) |
| 67 | + ..addMultiOption('target-arch', |
| 68 | + allowed: ['arm64', 'x64'], defaultsTo: [hostArch]); |
| 69 | + final argResults = argParser.parse(args); |
| 70 | + |
| 71 | + final targetOs = argResults.option('target-os')!; |
| 72 | + final targetArchs = argResults.multiOption('target-arch'); |
| 73 | + |
| 74 | + final artifacts = <String>[]; |
| 75 | + for (final targetArch in targetArchs) { |
| 76 | + final artifact = await _build( |
| 77 | + targetOs: targetOs, |
| 78 | + targetArch: targetArch, |
| 79 | + ); |
| 80 | + artifacts.add(artifact); |
| 81 | + } |
| 82 | + |
| 83 | + if (platform.environment['GITHUB_OUTPUT'] case final ciOutput?) { |
| 84 | + fileSystem.file(ciOutput).writeAsStringSync( |
| 85 | + 'version=$version\n' |
| 86 | + 'artifacts=${jsonEncode(artifacts)}\n', |
| 87 | + mode: FileMode.append, |
| 88 | + flush: true, |
| 89 | + ); |
| 90 | + } |
| 91 | +} |
| 92 | + |
| 93 | +Future<String> _build({ |
| 94 | + required String targetOs, |
| 95 | + required String targetArch, |
| 96 | +}) async { |
| 97 | + print('Bundling CLI version $version for $targetOs-$targetArch...'); |
| 98 | + |
| 99 | + if (buildDir.existsSync()) { |
| 100 | + buildDir.deleteSync(recursive: true); |
| 101 | + } |
| 102 | + await buildDir.create(recursive: true); |
| 103 | + await _runProcess( |
| 104 | + 'dart', |
| 105 | + [ |
| 106 | + if (currentSha case final currentSha?) '--define=gitSha=$currentSha', |
| 107 | + '--define=version=$version', |
| 108 | + 'compile', |
| 109 | + 'exe', |
| 110 | + if (targetOs != hostOs || targetArch != hostArch) ...[ |
| 111 | + '--target-os=$targetOs', |
| 112 | + '--target-arch=$targetArch', |
| 113 | + '--experimental-cross-compilation', |
| 114 | + ], |
| 115 | + '--output=$buildPath/celest.exe', |
| 116 | + 'bin/celest.dart', |
| 117 | + ], |
| 118 | + workingDirectory: platform.script.resolve('..').toFilePath(), |
| 119 | + ); |
| 120 | + if (!buildDir.existsSync()) { |
| 121 | + throw StateError('Build directory does not exist'); |
| 122 | + } |
| 123 | + |
| 124 | + if (!platform.isWindows) { |
| 125 | + final exeUri = platform.script.resolve('../$buildPath/celest.exe'); |
| 126 | + final exe = fileSystem.file(exeUri); |
| 127 | + final destExe = p.withoutExtension(p.absolute(exeUri.path)); |
| 128 | + if (!exe.existsSync() && !fileSystem.file(destExe).existsSync()) { |
| 129 | + throw StateError('Executable does not exist: $exe'); |
| 130 | + } |
| 131 | + exe.renameSync(destExe); |
| 132 | + } |
| 133 | + |
| 134 | + final bundler = switch (targetOs) { |
| 135 | + 'linux' => LinuxDebBundler(arch: targetArch), |
| 136 | + _ => throw UnsupportedError('Unsupported OS: $targetOs'), |
| 137 | + }; |
| 138 | + |
| 139 | + print('Bundling with ${bundler.runtimeType}...'); |
| 140 | + await bundler.bundle(); |
| 141 | + |
| 142 | + print('Successfully wrote ${bundler.outputFilepath}'); |
| 143 | + return bundler.outputFilepath; |
| 144 | +} |
| 145 | + |
| 146 | +abstract class Bundler { |
| 147 | + String get os; |
| 148 | + String get arch; |
| 149 | + String get extension; |
| 150 | + |
| 151 | + /// The path to the output file, dependent on the OS/arch. |
| 152 | + String get outputFilepath => p.join( |
| 153 | + outputsDir.path, |
| 154 | + 'celest_cli-$os-$arch.$extension', |
| 155 | + ); |
| 156 | + |
| 157 | + /// Bundles the CLI and its dependencies into a single file, performing |
| 158 | + /// code signing and notarization as needed. |
| 159 | + Future<void> bundle(); |
| 160 | +} |
| 161 | + |
| 162 | +final class LinuxArchiveBundler extends Bundler { |
| 163 | + LinuxArchiveBundler({required this.arch}); |
| 164 | + |
| 165 | + @override |
| 166 | + String get os => 'linux'; |
| 167 | + |
| 168 | + @override |
| 169 | + final String arch; |
| 170 | + |
| 171 | + @override |
| 172 | + String get extension => 'tar.gz'; |
| 173 | + |
| 174 | + @override |
| 175 | + Future<void> bundle() async { |
| 176 | + // Encode a directory from disk to disk, no memory |
| 177 | + final encoder = TarFileEncoder(); |
| 178 | + await encoder.tarDirectory( |
| 179 | + buildDir, |
| 180 | + compression: TarFileEncoder.gzip, |
| 181 | + filename: outputFilepath, |
| 182 | + ); |
| 183 | + } |
| 184 | +} |
| 185 | + |
| 186 | +final class LinuxDebBundler extends Bundler { |
| 187 | + LinuxDebBundler({required this.arch}); |
| 188 | + |
| 189 | + @override |
| 190 | + String get os => 'linux'; |
| 191 | + |
| 192 | + @override |
| 193 | + final String arch; |
| 194 | + |
| 195 | + @override |
| 196 | + String get extension => 'deb'; |
| 197 | + |
| 198 | + @override |
| 199 | + Future<void> bundle() async { |
| 200 | + /// Creates the DEB file structure. |
| 201 | + /// |
| 202 | + /// DEBIAN/ |
| 203 | + /// control |
| 204 | + /// opt/ |
| 205 | + /// celest/ |
| 206 | + /// celest |
| 207 | + print('Creating Debian archive...'); |
| 208 | + |
| 209 | + final debDir = tempDir.childDirectory('deb')..createSync(); |
| 210 | + final debControlDir = debDir.childDirectory('DEBIAN')..createSync(); |
| 211 | + final debInstallDir = debDir.childDirectory('opt').childDirectory('celest') |
| 212 | + ..createSync(recursive: true); |
| 213 | + |
| 214 | + final toolDebianDir = toolDir.childDirectory('linux').childDirectory('deb'); |
| 215 | + |
| 216 | + for (final controlFile |
| 217 | + in toolDebianDir.childDirectory('DEBIAN').listSync().cast<File>()) { |
| 218 | + if (p.basename(controlFile.path) == 'control') { |
| 219 | + final outputControlFile = debControlDir.childFile('control'); |
| 220 | + final outputControl = Template( |
| 221 | + controlFile.readAsStringSync(), |
| 222 | + ).renderString({ |
| 223 | + 'arch': switch (osArch) { |
| 224 | + Abi.linuxArm64 => 'arm64', |
| 225 | + Abi.linuxX64 => 'amd64', |
| 226 | + _ => unreachable(), |
| 227 | + }, |
| 228 | + 'version': version, |
| 229 | + }); |
| 230 | + print('Writing control contents:\n\n$outputControl\n'); |
| 231 | + await outputControlFile.writeAsString(outputControl); |
| 232 | + } else { |
| 233 | + controlFile.copySync( |
| 234 | + p.join(debControlDir.path, p.basename(controlFile.path)), |
| 235 | + ); |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + for (final installFile in buildDir.listSync().cast<File>()) { |
| 240 | + installFile.copySync( |
| 241 | + p.join(debInstallDir.path, p.basename(installFile.path)), |
| 242 | + ); |
| 243 | + } |
| 244 | + |
| 245 | + // Print directory structure |
| 246 | + _printFs(debDir); |
| 247 | + |
| 248 | + await _runProcess( |
| 249 | + 'dpkg-deb', |
| 250 | + [ |
| 251 | + '--build', |
| 252 | + debDir.path, |
| 253 | + outputFilepath, |
| 254 | + ], |
| 255 | + workingDirectory: tempDir.path, |
| 256 | + ); |
| 257 | + } |
| 258 | +} |
| 259 | + |
| 260 | +Future<String> _runProcess( |
| 261 | + String executable, |
| 262 | + List<String> args, { |
| 263 | + String? workingDirectory, |
| 264 | + Future<void> Function(String logs)? onError, |
| 265 | +}) async { |
| 266 | + print('Running process "$executable ${args.join(' ')}"...'); |
| 267 | + final proc = await processManager.start(<String>[ |
| 268 | + executable, |
| 269 | + ...args, |
| 270 | + ], workingDirectory: workingDirectory); |
| 271 | + |
| 272 | + // For logging |
| 273 | + executable = executable == 'xcrun' ? args.first : executable; |
| 274 | + args = executable == 'xcrun' ? args.skip(1).toList() : args; |
| 275 | + |
| 276 | + final logsBuf = StringBuffer(); |
| 277 | + proc.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen(( |
| 278 | + event, |
| 279 | + ) { |
| 280 | + logsBuf.writeln(event); |
| 281 | + stdout.writeln('$executable: $event'); |
| 282 | + }); |
| 283 | + proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(( |
| 284 | + event, |
| 285 | + ) { |
| 286 | + stderr.writeln('$executable: $event'); |
| 287 | + }); |
| 288 | + final exitCode = await proc.exitCode; |
| 289 | + final logs = logsBuf.toString(); |
| 290 | + if (exitCode != 0) { |
| 291 | + await onError?.call(logs); |
| 292 | + throw ProcessException(executable, args, 'Process failed', exitCode); |
| 293 | + } |
| 294 | + return logs; |
| 295 | +} |
| 296 | + |
| 297 | +void _printFs(Directory dir) { |
| 298 | + print('${dir.path} file structure:'); |
| 299 | + print('---------------------'); |
| 300 | + for (final entity in dir.listSync(recursive: true)) { |
| 301 | + final type = switch (fileSystem.typeSync(entity.path)) { |
| 302 | + FileSystemEntityType.directory => 'D', |
| 303 | + FileSystemEntityType.file => 'F', |
| 304 | + FileSystemEntityType.link => 'L', |
| 305 | + _ => '?', |
| 306 | + }; |
| 307 | + final relativePath = p.relative(entity.path, from: dir.path); |
| 308 | + print('$type $relativePath'); |
| 309 | + } |
| 310 | + print('---------------------'); |
| 311 | +} |
0 commit comments