diff --git a/tenant-service/src/main/java/com/xuqm/tenant/config/PrivateTenantBootstrapInitializer.java b/tenant-service/src/main/java/com/xuqm/tenant/config/PrivateTenantBootstrapInitializer.java index e592aaf..688950e 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/config/PrivateTenantBootstrapInitializer.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/config/PrivateTenantBootstrapInitializer.java @@ -1,10 +1,7 @@ package com.xuqm.tenant.config; -import com.xuqm.tenant.entity.AppEntity; import com.xuqm.tenant.entity.TenantEntity; -import com.xuqm.tenant.repository.AppRepository; import com.xuqm.tenant.repository.TenantRepository; -import com.xuqm.tenant.service.SdkAppProvisioningService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -30,8 +27,6 @@ public class PrivateTenantBootstrapInitializer implements ApplicationRunner { private final PrivateDeploymentProperties deployProps; private final TenantRepository tenantRepository; - private final AppRepository appRepository; - private final SdkAppProvisioningService provisioningService; private final PasswordEncoder passwordEncoder; @Value("${tenant.bootstrap.email:admin@customer.com}") @@ -43,18 +38,11 @@ public class PrivateTenantBootstrapInitializer implements ApplicationRunner { @Value("${tenant.bootstrap.username:admin}") private String bootstrapUsername; - @Value("${sdk.bootstrap-app-key:ak_private_default}") - private String bootstrapAppKey; - public PrivateTenantBootstrapInitializer(PrivateDeploymentProperties deployProps, TenantRepository tenantRepository, - AppRepository appRepository, - SdkAppProvisioningService provisioningService, PasswordEncoder passwordEncoder) { this.deployProps = deployProps; this.tenantRepository = tenantRepository; - this.appRepository = appRepository; - this.provisioningService = provisioningService; this.passwordEncoder = passwordEncoder; } @@ -84,16 +72,5 @@ public class PrivateTenantBootstrapInitializer implements ApplicationRunner { } else { log.info("[PRIVATE] Bootstrap tenant already exists: {}", bootstrapEmail); } - - AppEntity app = appRepository.findByAppKey(bootstrapAppKey).orElse(null); - if (app == null) { - provisioningService.ensureApp(bootstrapAppKey, true); - log.info("[PRIVATE] Bootstrap app created: {}", bootstrapAppKey); - } else if (!app.isDefault() || app.isDeletable()) { - app.setDefault(true); - app.setDeletable(false); - appRepository.save(app); - log.info("[PRIVATE] Bootstrap app marked as system app: {}", bootstrapAppKey); - } } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/config/SdkAppInitializer.java b/tenant-service/src/main/java/com/xuqm/tenant/config/SdkAppInitializer.java index 649d7eb..6cec850 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/config/SdkAppInitializer.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/config/SdkAppInitializer.java @@ -2,7 +2,6 @@ package com.xuqm.tenant.config; import com.xuqm.tenant.entity.AppEntity; import com.xuqm.tenant.repository.AppRepository; -import com.xuqm.tenant.service.SdkAppProvisioningService; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; @@ -11,8 +10,6 @@ import org.springframework.stereotype.Component; @Component public class SdkAppInitializer implements ApplicationRunner { - private final SdkAppProvisioningService provisioningService; - private final PrivateDeploymentProperties deployProps; private final AppRepository appRepository; @Value("${sdk.bootstrap-app-key:ak_demo_chat}") @@ -21,20 +18,13 @@ public class SdkAppInitializer implements ApplicationRunner { @Value("${sdk.im-platform-app-key:ak_409e217e4aa14254ad73ad3c}") private String imPlatformAppKey; - public SdkAppInitializer(SdkAppProvisioningService provisioningService, - PrivateDeploymentProperties deployProps, - AppRepository appRepository) { - this.provisioningService = provisioningService; - this.deployProps = deployProps; + public SdkAppInitializer(AppRepository appRepository) { this.appRepository = appRepository; } @Override public void run(ApplicationArguments args) { migrateExistingSystemApps(); - if (!deployProps.isPrivate()) { - provisioningService.ensureBootstrapApp(); - } } private void migrateExistingSystemApps() { diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/SystemUpdateController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/SystemUpdateController.java index bf819b1..8ac2648 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/SystemUpdateController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/SystemUpdateController.java @@ -23,8 +23,8 @@ public class SystemUpdateController { } /** - * 触发私有化部署一键升级,以流式文本返回进度日志。 - * 仅在 PRIVATE 模式下可用;需要 JWT 认证(租户账号即可)。 + * 拉取最新镜像并重建所有容器。耗时较长(需 docker pull)。 + * 仅 PRIVATE 模式可用。 */ @PostMapping(value = "/update", produces = MediaType.TEXT_PLAIN_VALUE) public ResponseEntity update() { @@ -33,16 +33,30 @@ public class SystemUpdateController { .contentType(MediaType.TEXT_PLAIN) .body(out -> out.write("此接口仅在私有化部署可用\n".getBytes())); } + return stream(emit -> updateService.runUpdate(emit)); + } - StreamingResponseBody body = outputStream -> { - updateService.runUpdate(line -> { - try { - outputStream.write((line + "\n").getBytes()); - outputStream.flush(); - } catch (Exception ignored) {} - }); - }; + /** + * 不拉取新镜像,直接用当前本地镜像重建所有容器。速度快,适合修复异常服务。 + * 仅 PRIVATE 模式可用。 + */ + @PostMapping(value = "/reset", produces = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity reset() { + if (!deployProps.isPrivate()) { + return ResponseEntity.status(403) + .contentType(MediaType.TEXT_PLAIN) + .body(out -> out.write("此接口仅在私有化部署可用\n".getBytes())); + } + return stream(emit -> updateService.runReset(emit)); + } + private ResponseEntity stream(java.util.function.Consumer> action) { + StreamingResponseBody body = outputStream -> action.accept(line -> { + try { + outputStream.write((line + "\n").getBytes()); + outputStream.flush(); + } catch (Exception ignored) {} + }); return ResponseEntity.ok() .contentType(MediaType.TEXT_PLAIN) .body(body); diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java index f6870e0..7e38b1e 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java @@ -2,181 +2,23 @@ package com.xuqm.tenant.service; import com.xuqm.common.exception.BusinessException; import com.xuqm.tenant.entity.AppEntity; -import com.xuqm.tenant.entity.FeatureServiceEntity; -import com.xuqm.tenant.entity.TenantEntity; import com.xuqm.tenant.repository.AppRepository; -import com.xuqm.tenant.repository.FeatureServiceRepository; -import com.xuqm.tenant.repository.TenantRepository; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.security.SecureRandom; -import java.time.LocalDateTime; -import java.util.Base64; -import java.util.List; -import java.util.UUID; - @Service public class SdkAppProvisioningService { - private static final SecureRandom RANDOM = new SecureRandom(); - private final AppRepository appRepository; - private final TenantRepository tenantRepository; - private final FeatureServiceRepository featureServiceRepository; - private final PasswordEncoder passwordEncoder; - @Value("${sdk.bootstrap-app-key:ak_demo_chat}") - private String bootstrapAppKey; - - @Value("${sdk.bootstrap-app-name:Demo Chat}") - private String bootstrapAppName; - - @Value("${sdk.bootstrap-app-package:com.xuqm.demo}") - private String bootstrapAppPackage; - - @Value("${sdk.bootstrap-app-description:XuqmGroup demo app}") - private String bootstrapAppDescription; - - public SdkAppProvisioningService(AppRepository appRepository, - TenantRepository tenantRepository, - FeatureServiceRepository featureServiceRepository, - PasswordEncoder passwordEncoder) { + public SdkAppProvisioningService(AppRepository appRepository) { this.appRepository = appRepository; - this.tenantRepository = tenantRepository; - this.featureServiceRepository = featureServiceRepository; - this.passwordEncoder = passwordEncoder; - } - - @Transactional - public AppEntity ensureBootstrapApp() { - return ensureApp(bootstrapAppKey, true); } @Transactional public AppEntity resolveApp(String appKey) { return appRepository.findByAppKey(appKey) .or(() -> appRepository.findById(appKey)) - .orElseGet(() -> { - if (!bootstrapAppKey.equals(appKey)) { - throw new BusinessException(404, "App not found: " + appKey); - } - return ensureApp(appKey, true); - }); - } - - @Transactional - public AppEntity ensureApp(String appKey, boolean bootstrapDefaults) { - AppEntity existing = appRepository.findByAppKey(appKey).orElse(null); - if (existing != null) { - if (bootstrapDefaults) { - ensureFeatureDefaults(existing); - } - return existing; - } - - TenantEntity owner = resolveOwnerTenant(); - AppEntity app = new AppEntity(); - app.setId(UUID.randomUUID().toString()); - app.setTenantId(owner.getId()); - app.setPackageName(bootstrapAppPackage); - app.setName(bootstrapAppName); - app.setDescription(bootstrapAppDescription); - app.setIconUrl(null); - app.setAppKey(appKey); - app.setAppSecret(generateSecret()); - app.setCreatedAt(LocalDateTime.now()); - app.setDefault(true); - app.setDeletable(false); - app = appRepository.save(app); - - if (bootstrapDefaults) { - ensureFeatureDefaults(app); - } - return app; - } - - private TenantEntity resolveOwnerTenant() { - return tenantRepository.findFirstByOrderByCreatedAtAsc() - .orElseGet(this::createBootstrapTenant); - } - - private TenantEntity createBootstrapTenant() { - TenantEntity tenant = new TenantEntity(); - tenant.setId(UUID.randomUUID().toString()); - tenant.setUsername("system"); - tenant.setPassword(passwordEncoder.encode(generateSecret())); - tenant.setEmail("system@xuqinmin.com"); - tenant.setNickname("System"); - tenant.setPhone(null); - tenant.setType(TenantEntity.Type.MAIN); - tenant.setStatus(TenantEntity.Status.ACTIVE); - tenant.setParentId(null); - tenant.setCreatedAt(LocalDateTime.now()); - return tenantRepository.save(tenant); - } - - private void ensureFeatureDefaults(AppEntity app) { - featureServiceRepository.findByAppKeyAndServiceType(app.getAppKey(), FeatureServiceEntity.ServiceType.IM) - .stream() - .findFirst() - .orElseGet(() -> { - FeatureServiceEntity feature = new FeatureServiceEntity(); - feature.setId(UUID.randomUUID().toString()); - feature.setAppKey(app.getAppKey()); - feature.setPlatform(FeatureServiceEntity.Platform.ANDROID); - feature.setServiceType(FeatureServiceEntity.ServiceType.IM); - feature.setEnabled(true); - feature.setConfig(""" - {"allowStrangerMessage":false,"allowFriendRequest":true,"friendRequestMode":"REQUIRE_CONFIRM","allowGroupJoinRequest":true,"blacklistSendSuccess":true,"messageRecallMinutes":2,"historyRetentionDays":7,"conversationPullLimit":100,"allowMultiDeviceLogin":true,"multiClientConversationDeleteSync":false} - """.trim()); - feature.setCreatedAt(LocalDateTime.now()); - return featureServiceRepository.save(feature); - }); - - for (FeatureServiceEntity.Platform platform : List.of( - FeatureServiceEntity.Platform.ANDROID, - FeatureServiceEntity.Platform.IOS, - FeatureServiceEntity.Platform.HARMONY)) { - for (FeatureServiceEntity.ServiceType serviceType : List.of( - FeatureServiceEntity.ServiceType.PUSH, - FeatureServiceEntity.ServiceType.UPDATE, - FeatureServiceEntity.ServiceType.LICENSE)) { - featureServiceRepository.findByAppKeyAndPlatformAndServiceType(app.getAppKey(), platform, serviceType) - .orElseGet(() -> { - FeatureServiceEntity feature = new FeatureServiceEntity(); - feature.setId(UUID.randomUUID().toString()); - feature.setAppKey(app.getAppKey()); - feature.setPlatform(platform); - feature.setServiceType(serviceType); - feature.setEnabled(true); - feature.setConfig(switch (serviceType) { - case UPDATE -> switch (platform) { - case ANDROID -> """ - {"defaultStoreTargets":[],"defaultPublishMode":"MANUAL","defaultPublishImmediately":false,"defaultScheduledPublishAt":"","defaultAutoPublishAfterReview":false,"defaultWebhookUrl":"","defaultForceUpdate":false,"defaultGrayEnabled":false,"defaultGrayPercent":0,"defaultPackageName":"","defaultAppStoreUrl":"","defaultMarketUrl":""} - """.trim(); - case IOS -> """ - {"defaultStoreTargets":["APP_STORE"],"defaultPublishMode":"MANUAL","defaultPublishImmediately":false,"defaultScheduledPublishAt":"","defaultAutoPublishAfterReview":false,"defaultWebhookUrl":"","defaultForceUpdate":false,"defaultGrayEnabled":false,"defaultGrayPercent":0,"defaultPackageName":"","defaultAppStoreUrl":"","defaultMarketUrl":""} - """.trim(); - case HARMONY -> """ - {"defaultStoreTargets":[],"defaultPublishMode":"MANUAL","defaultPublishImmediately":false,"defaultScheduledPublishAt":"","defaultAutoPublishAfterReview":false,"defaultWebhookUrl":"","defaultForceUpdate":false,"defaultGrayEnabled":false,"defaultGrayPercent":0,"defaultPackageName":"","defaultAppStoreUrl":"","defaultMarketUrl":""} - """.trim(); - }; - case LICENSE -> "{\"maxDevices\":1}"; - default -> null; - }); - feature.setCreatedAt(LocalDateTime.now()); - return featureServiceRepository.save(feature); - }); - } - } - } - - private String generateSecret() { - byte[] bytes = new byte[32]; - RANDOM.nextBytes(bytes); - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + .orElseThrow(() -> new BusinessException(404, "App not found: " + appKey)); } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java index f6595aa..a02001a 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java @@ -28,43 +28,49 @@ public class SystemUpdateService { @Value("${PRIVATE_DEPLOY_ROOT:/opt/xuqm-private}") private String deployRoot; + /** 拉取最新镜像并重建所有容器。 */ public void runUpdate(Consumer emit) { String composeFile = deployRoot + "/docker-compose.yml"; - // Step 1: authenticate to registry dockerLogin(emit); - - // Step 2: apply any pending config patches (idempotent) patchConfigs(emit); - // Step 3: pull images emit.accept(">>> 拉取最新镜像..."); for (String svc : OTHER_SERVICES) { - if (isRunning(svc)) { - emit.accept(" pulling " + svc + " ..."); - exec(emit, "docker", "compose", "-f", composeFile, "pull", "--quiet", svc); - } + emit.accept(" pulling " + svc + " ..."); + exec(emit, "docker", "compose", "-f", composeFile, "pull", "--quiet", svc); } emit.accept(" pulling tenant-service ..."); exec(emit, "docker", "compose", "-f", composeFile, "pull", "--quiet", "tenant-service"); emit.accept(">>> 镜像拉取完成"); - // Step 4: restart other services (nginx last so patched conf is applied) - emit.accept(">>> 重启各服务..."); + restartAndSelfUpdate(emit, composeFile); + } + + /** 不拉取新镜像,直接用当前本地镜像重建所有容器。 */ + public void runReset(Consumer emit) { + String composeFile = deployRoot + "/docker-compose.yml"; + + patchConfigs(emit); + restartAndSelfUpdate(emit, composeFile); + } + + // ── Shared core ─────────────────────────────────────────────────────────── + + private void restartAndSelfUpdate(Consumer emit, String composeFile) { + emit.accept(">>> 重建各服务容器..."); for (String svc : OTHER_SERVICES) { - if (isRunning(svc)) { - emit.accept(" restarting " + svc + " ..."); - exec(emit, "docker", "compose", "-f", composeFile, - "up", "-d", "--no-deps", "--force-recreate", svc); - emit.accept(" " + svc + " ✓"); - } + emit.accept(" restarting " + svc + " ..."); + exec(emit, "docker", "compose", "-f", composeFile, + "up", "-d", "--no-deps", "--force-recreate", svc); + emit.accept(" " + svc + " ✓"); } - // Step 5: self-update tenant-service via detached helper container emit.accept(">>> 启动自更新助手容器..."); String selfImage = getCurrentImage(); if (selfImage == null) { - emit.accept(">>> [错误] 无法获取当前 tenant-service 镜像名,请检查容器标签或手动执行更新。"); + emit.accept(">>> [错误] 无法获取当前 tenant-service 镜像名,请手动执行:"); + emit.accept(">>> docker compose -f " + composeFile + " up -d --no-deps --force-recreate tenant-service"); emit.accept("DONE"); return; } @@ -80,10 +86,8 @@ public class SystemUpdateService { } } - /** - * Idempotent config patches — fixes known misconfigurations introduced in earlier deploy versions. - * Each patch checks before writing so running this multiple times is safe. - */ + // ── Config patchers ─────────────────────────────────────────────────────── + private void patchConfigs(Consumer emit) { emit.accept(">>> 检查并修复配置文件..."); patchNginxFileRoute(emit); @@ -91,24 +95,20 @@ public class SystemUpdateService { patchDockerComposeFileService(emit); } - /** - * nginx: add exact-match location for /api/system/update with 600s timeout so that - * docker pull (which may be silent for minutes) doesn't hit the 60s proxy_read_timeout - * on the generic /api/ block and cause ERR_INCOMPLETE_CHUNKED_ENCODING. - */ private void patchNginxUpdateTimeout(Consumer emit) { Path conf = Paths.get(deployRoot, "config", "nginx", "conf.d", "xuqm.conf"); if (!Files.exists(conf)) return; try { String content = Files.readString(conf); - if (content.contains("location = /api/system/update")) return; + // Already patched with regex location (new format) or exact-match (old format) + if (content.contains("location ~ ^/api/system/") || content.contains("location = /api/system/update")) return; String anchor = " # 核心 API(兜底,在所有具体 /api/xxx/ 之后)\n location /api/ {"; if (!content.contains(anchor)) { emit.accept(" [跳过] nginx 更新超时补丁锚点未找到,请手动检查"); return; } - String injection = " # 一键更新:docker pull 可能耗时数分钟,需要更长超时(精确匹配,优先于 /api/ 前缀)\n" - + " location = /api/system/update {\n" + String injection = " # 一键更新/重置:操作耗时较长,需要更长超时(精确匹配,优先于 /api/ 前缀)\n" + + " location ~ ^/api/system/(update|reset)$ {\n" + " set $svc tenant-service;\n" + " proxy_pass http://$svc:9001;\n" + " proxy_set_header Host $host;\n" @@ -120,13 +120,12 @@ public class SystemUpdateService { + anchor; String patched = content.replace(anchor, injection); Files.writeString(conf, patched, StandardOpenOption.TRUNCATE_EXISTING); - emit.accept(" [已修复] nginx: 补齐 /api/system/update 600s 超时"); + emit.accept(" [已修复] nginx: 补齐 /api/system/(update|reset) 600s 超时"); } catch (IOException e) { emit.accept(" [警告] nginx 更新超时修复失败: " + e.getMessage()); } } - /** nginx: location /file/ must be location /api/file/ to match the file controller path. */ private void patchNginxFileRoute(Consumer emit) { Path conf = Paths.get(deployRoot, "config", "nginx", "conf.d", "xuqm.conf"); if (!Files.exists(conf)) return; @@ -141,10 +140,6 @@ public class SystemUpdateService { } } - /** - * docker-compose: ensure FILE_UPLOAD_DIR and FILE_BASE_URL are set for file-service. - * Reads CONSOLE_DOMAIN from xuqm.env to determine the correct base URL. - */ private void patchDockerComposeFileService(Consumer emit) { Path composeFile = Paths.get(deployRoot, "docker-compose.yml"); if (!Files.exists(composeFile)) return; @@ -155,8 +150,6 @@ public class SystemUpdateService { String consoleDomain = readEnvValue(Paths.get(deployRoot, "config", "xuqm.env"), "CONSOLE_DOMAIN"); if (consoleDomain == null) consoleDomain = ""; - // Inject the missing env vars directly after SPRING_DATA_REDIS_DATABASE line in file-service block. - // This pattern is stable — the line is unique in the file-service environment section. String anchor = " SPRING_DATA_REDIS_DATABASE: \"${REDIS_DATABASE:-0}\"\n"; if (!content.contains(anchor)) { emit.accept(" [跳过] docker-compose 文件-服务补丁锚点未找到,请手动检查"); @@ -173,28 +166,14 @@ public class SystemUpdateService { } } - private String readEnvValue(Path envFile, String key) { - if (!Files.exists(envFile)) return null; - try { - for (String line : Files.readAllLines(envFile)) { - if (line.startsWith(key + "=")) { - return line.substring(key.length() + 1).trim(); - } - } - } catch (IOException ignored) {} - return null; - } + // ── Docker helpers ──────────────────────────────────────────────────────── - /** - * Read REGISTRY / REGISTRY_USER / REGISTRY_PASSWORD from deployRoot/.env and - * run "docker login" so that subsequent pulls succeed on private registries. - */ private void dockerLogin(Consumer emit) { try { String registry = null, user = null, password = null; for (String line : Files.readAllLines(Paths.get(deployRoot + "/.env"))) { - if (line.startsWith("REGISTRY=")) registry = line.substring("REGISTRY=".length()).trim(); - else if (line.startsWith("REGISTRY_USER=")) user = line.substring("REGISTRY_USER=".length()).trim(); + if (line.startsWith("REGISTRY=")) registry = line.substring("REGISTRY=".length()).trim(); + else if (line.startsWith("REGISTRY_USER=")) user = line.substring("REGISTRY_USER=".length()).trim(); else if (line.startsWith("REGISTRY_PASSWORD=")) password = line.substring("REGISTRY_PASSWORD=".length()).trim(); } if (registry == null || user == null || password == null || password.isEmpty()) return; @@ -217,9 +196,6 @@ public class SystemUpdateService { } } - /** - * 启动一个独立的 detached 容器,在 tenant-service 被停止后重建它。 - */ private boolean spawnSelfUpdater(String composeFile, String image) { try { new ProcessBuilder("docker", "rm", "-f", "xuqm-self-updater") @@ -248,20 +224,6 @@ public class SystemUpdateService { } } - private boolean isRunning(String service) { - try { - Process p = new ProcessBuilder( - "docker", "ps", "-q", - "--filter", "label=com.docker.compose.service=" + service - ).redirectErrorStream(true).start(); - String out = new String(p.getInputStream().readAllBytes()).trim(); - p.waitFor(); - return !out.isEmpty(); - } catch (Exception e) { - return false; - } - } - private String getCurrentImage() { try { Process p = new ProcessBuilder( @@ -277,6 +239,18 @@ public class SystemUpdateService { } } + private String readEnvValue(Path envFile, String key) { + if (!Files.exists(envFile)) return null; + try { + for (String line : Files.readAllLines(envFile)) { + if (line.startsWith(key + "=")) { + return line.substring(key.length() + 1).trim(); + } + } + } catch (IOException ignored) {} + return null; + } + private void exec(Consumer emit, String... cmd) { try { Process p = new ProcessBuilder(cmd)