@archznn/xavva 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # XAVVA CLI 🚀
2
2
 
3
- > Ultra-fast development toolkit for Java Enterprise (Tomcat) on Windows
3
+ > Ultra-fast development toolkit for Java Enterprise (Tomcat) on Windows, Linux & macOS
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.4.0-blue.svg)](https://github.com/leorsousa05/Xavva)
5
+ [![Version](https://img.shields.io/badge/version-2.6.0-blue.svg)](https://github.com/leorsousa05/Xavva)
6
6
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
7
 
8
8
  Xavva is a high-performance CLI built with **Bun** that transforms the Java/Tomcat development experience. It brings modern development workflows (like Node.js/Vite) to the Java Enterprise ecosystem with hot-reload, smart logging, and automated deployment.
@@ -25,7 +25,7 @@ Xavva is a high-performance CLI built with **Bun** that transforms the Java/Tomc
25
25
 
26
26
  ## 📦 Installation
27
27
 
28
- ```powershell
28
+ ```bash
29
29
  # Via NPM
30
30
  npm install -g @archznn/xavva
31
31
 
@@ -196,12 +196,14 @@ Create `xavva.json` in your project root:
196
196
  "tui": false
197
197
  },
198
198
  "tomcat": {
199
- "path": "C:/apache-tomcat",
199
+ "path": "/home/user/apache-tomcat",
200
200
  "port": 8080
201
201
  }
202
202
  }
203
203
  ```
204
204
 
205
+ > **Note:** On Windows use `"path": "C:/apache-tomcat"` format.
206
+
205
207
  ### CLI Options
206
208
 
207
209
  | Option | Description |
@@ -224,6 +226,25 @@ Create `xavva.json` in your project root:
224
226
 
225
227
  ---
226
228
 
229
+ ## 💻 Platform Support
230
+
231
+ Xavva works on all major platforms:
232
+
233
+ | Platform | Status | Notes |
234
+ |----------|--------|-------|
235
+ | Windows | ✅ Full | PowerShell for system integration |
236
+ | Linux | ✅ Full | Bash/Zsh auto-configuration |
237
+ | macOS | ✅ Full | Native terminal support |
238
+
239
+ ### Requirements
240
+
241
+ - **Bun** runtime (latest version)
242
+ - **Java** 11 or higher (JDK)
243
+ - **Maven** 3.6+ or **Gradle** 7+
244
+ - **Git** (optional, for version info)
245
+
246
+ ---
247
+
227
248
  ## 🏗️ Architecture
228
249
 
229
250
  Xavva uses a modular service-oriented architecture:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@archznn/xavva",
3
- "version": "2.5.0",
4
- "description": "Ultra-fast CLI tool for Java/Tomcat development on Windows with Hot-Reload and Zero Config.",
3
+ "version": "2.6.0",
4
+ "description": "Ultra-fast CLI tool for Java/Tomcat development with Hot-Reload and Zero Config. Supports Windows, Linux and macOS.",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
7
7
  "bin": {
@@ -19,6 +19,8 @@
19
19
  "gradle",
20
20
  "hot-reload",
21
21
  "windows",
22
+ "linux",
23
+ "macos",
22
24
  "cli",
23
25
  "bun"
24
26
  ],
@@ -7,6 +7,11 @@ import { TomcatService } from "../services/TomcatService";
7
7
  import { Logger } from "../utils/ui";
8
8
  import { EndpointService } from "../services/EndpointService";
9
9
  import { BrowserService } from "../services/BrowserService";
10
+ import {
11
+ getJavaPath,
12
+ getWarExtractCommand,
13
+ isWindows,
14
+ } from "../utils/platform";
10
15
 
11
16
  export class DeployCommand implements Command {
12
17
  constructor(private tomcat: TomcatService, private builder: BuildService) {}
@@ -78,15 +83,28 @@ export class DeployCommand implements Command {
78
83
  Bun.spawnSync(["jar", "xf", artifactInfo.path], { cwd: appWebappPath });
79
84
  Logger.server("extracted WAR");
80
85
  } catch (e) {
81
- const extractCmd = `Expand-Archive -Path $env:ARTIFACT_PATH -DestinationPath $env:DEST_PATH -Force`;
82
- Bun.spawnSync(["powershell", "-command", extractCmd], {
83
- env: {
84
- ...process.env,
85
- ARTIFACT_PATH: artifactInfo.path,
86
- DEST_PATH: appWebappPath
86
+ // Fallback para extração com jar (funciona em todas as plataformas)
87
+ if (isWindows()) {
88
+ const extractCmd = `Expand-Archive -Path $env:ARTIFACT_PATH -DestinationPath $env:DEST_PATH -Force`;
89
+ Bun.spawnSync(["powershell", "-command", extractCmd], {
90
+ env: {
91
+ ...process.env,
92
+ ARTIFACT_PATH: artifactInfo.path,
93
+ DEST_PATH: appWebappPath
94
+ }
95
+ });
96
+ } else {
97
+ // Linux/Mac: usa unzip ou jar
98
+ try {
99
+ Bun.spawnSync(["unzip", "-q", "-o", artifactInfo.path, "-d", appWebappPath]);
100
+ } catch {
101
+ // Fallback final para jar
102
+ Bun.spawnSync(getWarExtractCommand(artifactInfo.path, appWebappPath), {
103
+ cwd: appWebappPath
104
+ });
87
105
  }
88
- });
89
- Logger.server("extracted WAR (legacy)");
106
+ }
107
+ Logger.server("extracted WAR (fallback)");
90
108
  }
91
109
  } else {
92
110
  Logger.server("webapp up to date");
@@ -117,13 +135,7 @@ export class DeployCommand implements Command {
117
135
  Logger.config("watch", isWatching);
118
136
  Logger.config("debug", config.project.debug ? `port ${config.project.debugPort}` : false);
119
137
 
120
- let javaBin = "java";
121
- if (process.env.JAVA_HOME) {
122
- const homeBin = path.join(process.env.JAVA_HOME, "bin", "java.exe");
123
- if (fs.existsSync(homeBin)) javaBin = homeBin;
124
- }
125
-
126
- const javaVer = Bun.spawnSync([javaBin, "-version"]);
138
+ const javaVer = Bun.spawnSync([getJavaPath(), "-version"]);
127
139
  const output = (javaVer.stderr.toString() + javaVer.stdout.toString()).toLowerCase();
128
140
  const hasDcevm = ["dcevm", "jetbrains", "trava", "jbr"].some(v => output.includes(v));
129
141
 
@@ -4,6 +4,17 @@ import { Logger } from "../utils/ui";
4
4
  import fs from "fs";
5
5
  import path from "path";
6
6
  import os from "os";
7
+ import {
8
+ getCatalinaScript,
9
+ getWhichCommand,
10
+ getJbrDownloadUrl,
11
+ getTarExtractCommand,
12
+ getJavaPath,
13
+ getJavaBinary,
14
+ isWindows,
15
+ isLinux,
16
+ isMacOS,
17
+ } from "../utils/platform";
7
18
 
8
19
  export class DoctorCommand implements Command {
9
20
  async execute(config: AppConfig, values: CLIArguments = {}): Promise<void> {
@@ -25,7 +36,7 @@ export class DoctorCommand implements Command {
25
36
 
26
37
  if (!jvmInfo.dcevm) {
27
38
  Logger.log(
28
- ` ${Logger.C.yellow}💡 Dica: Sua JVM não suporta mudanças estruturais (novos métodos/campos).${Logger.C.reset}`,
39
+ ` ${Logger.C.warning}💡 Dica: Sua JVM não suporta mudanças estruturais (novos métodos/campos).${Logger.C.reset}`,
29
40
  );
30
41
  if (values.fix) {
31
42
  await this.installDCEVM();
@@ -41,12 +52,12 @@ export class DoctorCommand implements Command {
41
52
 
42
53
  if (tomcatOk) {
43
54
  const binOk = fs.existsSync(
44
- path.join(config.tomcat.path, "bin", "catalina.bat"),
55
+ path.join(config.tomcat.path, "bin", getCatalinaScript()),
45
56
  );
46
57
  this.check(
47
58
  "Tomcat Bin",
48
59
  binOk,
49
- binOk ? "OK" : "catalina.bat não encontrado",
60
+ binOk ? "OK" : `${getCatalinaScript()} não encontrado`,
50
61
  );
51
62
  }
52
63
 
@@ -211,10 +222,7 @@ export class DoctorCommand implements Command {
211
222
 
212
223
  private checkBinary(name: string): boolean {
213
224
  try {
214
- const proc = Bun.spawnSync([
215
- process.platform === "win32" ? "where" : "which",
216
- name,
217
- ]);
225
+ const proc = Bun.spawnSync(getWhichCommand(name));
218
226
  return proc.exitCode === 0;
219
227
  } catch {
220
228
  return false;
@@ -224,11 +232,7 @@ export class DoctorCommand implements Command {
224
232
  private checkJVM(): { name: string, dcevm: boolean } {
225
233
  try {
226
234
  // Tentar primeiro o binário do JAVA_HOME para evitar cache do Path
227
- let javaBin = "java";
228
- if (process.env.JAVA_HOME) {
229
- const homeBin = path.join(process.env.JAVA_HOME, "bin", "java.exe");
230
- if (fs.existsSync(homeBin)) javaBin = homeBin;
231
- }
235
+ let javaBin = getJavaPath();
232
236
 
233
237
  const proc = Bun.spawnSync([javaBin, "-version"]);
234
238
  const output = (proc.stderr.toString() + proc.stdout.toString()).toLowerCase();
@@ -251,8 +255,8 @@ export class DoctorCommand implements Command {
251
255
  Logger.section("Instalação do JetBrains Runtime (JBR 21)");
252
256
  Logger.log("Baixando JDK moderna com DCEVM nativo (JBR 21 SDK)...");
253
257
 
254
- // URL para o JetBrains Runtime 21 SDK Windows x64
255
- const url = "https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-21.0.6-windows-x64-b895.97.tar.gz";
258
+ // URL para o JetBrains Runtime 21 SDK (multiplataforma)
259
+ const url = getJbrDownloadUrl("21");
256
260
  const installDir = path.join(os.homedir(), ".xavva", "jdk-dcevm");
257
261
 
258
262
  // Limpar instalação anterior se existir
@@ -272,21 +276,15 @@ export class DoctorCommand implements Command {
272
276
 
273
277
  Logger.success("Download concluído. Extraindo binários...");
274
278
 
275
- // Usar PowerShell para extrair .tar.gz (nativo no Windows 10/11)
276
- const extractCmd = `tar -xzf $env:TAR_PATH -C $env:INSTALL_DIR`;
277
- Bun.spawnSync(["powershell", "-command", extractCmd], {
278
- env: {
279
- ...process.env,
280
- TAR_PATH: tarPath,
281
- INSTALL_DIR: installDir
282
- }
283
- });
279
+ // Extrair .tar.gz usando comando apropriado para a plataforma
280
+ const extractCmd = getTarExtractCommand(tarPath, installDir);
281
+ Bun.spawnSync(extractCmd);
284
282
 
285
283
  fs.rmSync(tarPath);
286
284
 
287
- // Busca recursiva para encontrar onde está o bin/java.exe
285
+ // Busca recursiva para encontrar onde está o bin/java
288
286
  const findJdkRoot = (dir: string): string | null => {
289
- if (fs.existsSync(path.join(dir, "bin", "java.exe"))) return dir;
287
+ if (fs.existsSync(path.join(dir, "bin", getJavaBinary()))) return dir;
290
288
  const subdirs = fs.readdirSync(dir, { withFileTypes: true })
291
289
  .filter(d => d.isDirectory())
292
290
  .map(d => path.join(dir, d.name));
@@ -299,55 +297,110 @@ export class DoctorCommand implements Command {
299
297
 
300
298
  const jdkPath = findJdkRoot(installDir) || installDir;
301
299
  const binPath = path.join(jdkPath, "bin");
302
-
303
- Logger.process("Configurando variáveis de ambiente do SISTEMA...");
304
-
305
- const setEnvCmd = `
306
- $jdk = $env:JDK_PATH;
307
- $bin = $env:BIN_PATH;
308
- try {
309
- [Environment]::SetEnvironmentVariable('JAVA_HOME', $jdk, 'Machine');
310
- $pathVar = [Environment]::GetEnvironmentVariable('Path', 'Machine');
311
- $paths = $pathVar -split ';' | Where-Object { $_ -ne '' };
312
- $normalizedBin = $bin.TrimEnd('\\').ToLower();
313
-
314
- $exists = $false;
315
- foreach ($p in $paths) {
316
- if ($p.TrimEnd('\\').ToLower() -eq $normalizedBin) { $exists = $true; break; }
317
- }
318
-
319
- if (-not $exists) {
320
- $newPath = "$bin;" + $pathVar;
321
- [Environment]::SetEnvironmentVariable('Path', $newPath, 'Machine');
322
- }
323
- Write-Output "OK";
324
- } catch {
325
- Write-Error $_.Exception.Message;
326
- }
327
- `.replace(/\n/g, ' ');
328
- const result = Bun.spawnSync(["powershell", "-command", setEnvCmd], {
329
- env: {
330
- ...process.env,
331
- JDK_PATH: jdkPath,
332
- BIN_PATH: binPath
333
- }
334
- });
335
- const output = result.stdout.toString() + result.stderr.toString();
336
-
337
- if (output.includes("ACCESS_DENIED")) {
338
- Logger.error("Falha ao configurar variáveis do SISTEMA (Acesso Negado).");
339
- Logger.warn("Dica: Execute o terminal como ADMINISTRADOR para permitir esta alteração.");
340
- Logger.info("JAVA_HOME manual", jdkPath);
341
- } else {
342
- Logger.success(`DCEVM configurado no SISTEMA com sucesso!`);
343
- Logger.info("JAVA_HOME", jdkPath);
344
- }
345
-
346
- Logger.newline();
347
- Logger.warn("IMPORTANTE: Reinicie seu terminal (ou o VS Code) para as mudanças surtirem efeito.");
348
- } catch (e) {
349
-
350
- Logger.error(`Falha na instalação: ${e.message}`);
351
- }
352
- }
300
+
301
+ if (isWindows()) {
302
+ await this.configureWindowsEnv(jdkPath, binPath);
303
+ } else {
304
+ await this.configureUnixEnv(jdkPath, binPath);
305
+ }
306
+ } catch (e: any) {
307
+ Logger.error(`Falha na instalação: ${e.message}`);
308
+ }
309
+ }
310
+
311
+ private async configureWindowsEnv(jdkPath: string, binPath: string) {
312
+ Logger.process("Configurando variáveis de ambiente do SISTEMA...");
313
+
314
+ const setEnvCmd = `
315
+ $jdk = $env:JDK_PATH;
316
+ $bin = $env:BIN_PATH;
317
+ try {
318
+ [Environment]::SetEnvironmentVariable('JAVA_HOME', $jdk, 'Machine');
319
+ $pathVar = [Environment]::GetEnvironmentVariable('Path', 'Machine');
320
+ $paths = $pathVar -split ';' | Where-Object { $_ -ne '' };
321
+ $normalizedBin = $bin.TrimEnd('\\\\').ToLower();
322
+
323
+ $exists = $false;
324
+ foreach ($p in $paths) {
325
+ if ($p.TrimEnd('\\\\').ToLower() -eq $normalizedBin) { $exists = $true; break; }
326
+ }
327
+
328
+ if (-not $exists) {
329
+ $newPath = "$bin;" + $pathVar;
330
+ [Environment]::SetEnvironmentVariable('Path', $newPath, 'Machine');
331
+ }
332
+ Write-Output "OK";
333
+ } catch {
334
+ Write-Error $_.Exception.Message;
335
+ }
336
+ `.replace(/\n/g, ' ');
337
+
338
+ const result = Bun.spawnSync(["powershell", "-command", setEnvCmd], {
339
+ env: {
340
+ ...process.env,
341
+ JDK_PATH: jdkPath,
342
+ BIN_PATH: binPath
343
+ }
344
+ });
345
+ const output = result.stdout.toString() + result.stderr.toString();
346
+
347
+ if (output.includes("ACCESS_DENIED")) {
348
+ Logger.error("Falha ao configurar variáveis do SISTEMA (Acesso Negado).");
349
+ Logger.warn("Dica: Execute o terminal como ADMINISTRADOR para permitir esta alteração.");
350
+ Logger.info("JAVA_HOME manual", jdkPath);
351
+ } else {
352
+ Logger.success(`DCEVM configurado no SISTEMA com sucesso!`);
353
+ Logger.info("JAVA_HOME", jdkPath);
354
+ }
355
+
356
+ Logger.newline();
357
+ Logger.warn("IMPORTANTE: Reinicie seu terminal (ou o VS Code) para as mudanças surtirem efeito.");
358
+ }
359
+
360
+ private async configureUnixEnv(jdkPath: string, binPath: string) {
361
+ Logger.process("Configurando variáveis de ambiente...");
362
+
363
+ const shell = process.env.SHELL || "/bin/bash";
364
+ let rcFile = path.join(os.homedir(), ".bashrc");
365
+
366
+ if (shell.includes("zsh")) {
367
+ rcFile = path.join(os.homedir(), ".zshrc");
368
+ } else if (shell.includes("fish")) {
369
+ rcFile = path.join(os.homedir(), ".config/fish/config.fish");
370
+ }
371
+
372
+ // Verifica se já existe JAVA_HOME configurado
373
+ const exportLine = `export JAVA_HOME="${jdkPath}"`;
374
+ const pathLine = `export PATH="${binPath}:$PATH"`;
375
+
376
+ let rcContent = "";
377
+ if (fs.existsSync(rcFile)) {
378
+ rcContent = fs.readFileSync(rcFile, "utf8");
379
+ }
380
+
381
+ // Remove linhas antigas do Xavva JBR
382
+ const lines = rcContent.split("\n");
383
+ const filteredLines = lines.filter(line =>
384
+ !line.includes("# Xavva JBR") &&
385
+ !line.includes("JAVA_HOME=") &&
386
+ !line.includes("# Added by Xavva")
387
+ );
388
+
389
+ // Adiciona novas configurações
390
+ filteredLines.push("# Added by Xavva - JetBrains Runtime (DCEVM)");
391
+ filteredLines.push(exportLine);
392
+ filteredLines.push(pathLine);
393
+
394
+ fs.writeFileSync(rcFile, filteredLines.join("\n") + "\n");
395
+
396
+ Logger.success(`DCEVM configurado em ${rcFile}`);
397
+ Logger.info("JAVA_HOME", jdkPath);
398
+ Logger.newline();
399
+ Logger.warn("IMPORTANTE: Execute 'source " + rcFile + "' ou reinicie seu terminal para aplicar permanentemente.");
400
+ Logger.info("Dica", "Para esta sessão, o JAVA_HOME já foi configurado temporariamente.");
401
+
402
+ // Configura JAVA_HOME temporariamente para o processo atual
403
+ process.env.JAVA_HOME = jdkPath;
404
+ process.env.PATH = `${binPath}:${process.env.PATH}`;
405
+ }
353
406
  }
@@ -60,7 +60,7 @@ export class LogsCommand implements Command {
60
60
  currentSize = newStats.size;
61
61
  } else if (newStats.size < currentSize) {
62
62
  currentSize = newStats.size;
63
- dashboard.log(Logger.C.yellow + "Arquivo de log foi resetado/rotacionado.");
63
+ dashboard.log(Logger.C.warning + "Arquivo de log foi resetado/rotacionado.");
64
64
  }
65
65
  }
66
66
  });
@@ -5,6 +5,14 @@ import path from "path";
5
5
  import fs from "fs";
6
6
  import { glob } from "glob";
7
7
  import readline from "readline";
8
+ import {
9
+ getJavaPath,
10
+ getMavenCommand,
11
+ getGradleCommand,
12
+ getClasspathSeparator,
13
+ normalizeClasspathPath,
14
+ isWindows,
15
+ } from "../utils/platform";
8
16
 
9
17
  export class RunCommand implements Command {
10
18
  async execute(config: AppConfig, args?: CLIArguments): Promise<void> {
@@ -36,7 +44,8 @@ export class RunCommand implements Command {
36
44
  const { localCp, dependencyCp } = await this.getClasspath(config);
37
45
  const pathingJar = await this.createPathingJar(dependencyCp);
38
46
 
39
- const finalCp = `${localCp};${pathingJar}`;
47
+ const sep = getClasspathSeparator();
48
+ const finalCp = `${localCp}${sep}${pathingJar}`;
40
49
 
41
50
  const javaArgs = [
42
51
  "-classpath", finalCp,
@@ -60,7 +69,7 @@ export class RunCommand implements Command {
60
69
  Logger.warn(`🚀 Executando ${className}...`);
61
70
  }
62
71
 
63
- const bin = process.env.JAVA_HOME ? path.join(process.env.JAVA_HOME, "bin", "java.exe") : "java";
72
+ const bin = getJavaPath();
64
73
 
65
74
  const proc = Bun.spawn([bin, ...javaArgs], {
66
75
  stdout: "inherit",
@@ -220,9 +229,10 @@ export class RunCommand implements Command {
220
229
  const xavvaDir = path.join(process.cwd(), ".xavva");
221
230
  const jarPath = path.join(xavvaDir, "classpath.jar");
222
231
 
223
- const paths = dependencyCp.split(";").filter(p => p.trim());
232
+ const sep = getClasspathSeparator();
233
+ const paths = dependencyCp.split(sep).filter(p => p.trim());
224
234
  const relativePaths = paths.map(p => {
225
- let rel = path.relative(xavvaDir, p).replace(/\\/g, "/");
235
+ let rel = normalizeClasspathPath(path.relative(xavvaDir, p));
226
236
  if (fs.existsSync(p) && fs.statSync(p).isDirectory() && !rel.endsWith("/")) rel += "/";
227
237
  // Robust URL encoding for Class-Path as per Java Spec
228
238
  return encodeURI(rel)
@@ -296,10 +306,9 @@ export class RunCommand implements Command {
296
306
  const stopSpinner = Logger.spinner("Generating project classpath");
297
307
  try {
298
308
  if (config.project.buildTool === "maven") {
299
- const mvnCmd = process.platform === "win32" ? "mvn.cmd" : "mvn";
300
- Bun.spawnSync([mvnCmd, "dependency:build-classpath", `-Dmdep.outputFile=${cpFile}`]);
309
+ Bun.spawnSync([getMavenCommand(), "dependency:build-classpath", `-Dmdep.outputFile=${cpFile}`]);
301
310
  } else if (config.project.buildTool === "gradle") {
302
- const gradleCmd = process.platform === "win32" ? "gradle.bat" : "gradle";
311
+ const gradleCmd = getGradleCommand();
303
312
  const initScriptPath = path.join(xavvaDir, "init-cp.gradle");
304
313
  const normalizedCpFile = cpFile.replace(/\\/g, "/");
305
314
  const initScriptContent = `
@@ -335,9 +344,10 @@ export class RunCommand implements Command {
335
344
 
336
345
  let dependencyCp = fs.existsSync(cpFile) ? fs.readFileSync(cpFile, "utf8").trim() : "";
337
346
 
338
- // Normalize platform specific separators to semicolon for consistency
339
- if (path.delimiter !== ";") {
340
- dependencyCp = dependencyCp.split(path.delimiter).join(";");
347
+ // Normalize platform specific separators para o separador consistente
348
+ const sep = getClasspathSeparator();
349
+ if (path.delimiter !== sep) {
350
+ dependencyCp = dependencyCp.split(path.delimiter).join(sep);
341
351
  }
342
352
 
343
353
  const localFolders = [
@@ -356,7 +366,7 @@ export class RunCommand implements Command {
356
366
  const localCp = localFolders
357
367
  .map(p => path.join(process.cwd(), p))
358
368
  .filter(p => fs.existsSync(p))
359
- .join(";");
369
+ .join(getClasspathSeparator());
360
370
 
361
371
  return { localCp, dependencyCp };
362
372
  }
package/src/index.ts CHANGED
@@ -93,7 +93,7 @@ async function main() {
93
93
  // Registrar ação de restart manual na TUI
94
94
  if (dashboard.isTuiActive()) {
95
95
  dashboard.onAction("r", () => {
96
- dashboard.log(Logger.C.yellow + "Restart manual solicitado via TUI...");
96
+ dashboard.log(Logger.C.warning + "Restart manual solicitado via TUI...");
97
97
  deployCmd.execute(config, false, true); // Executa deploy completo mas mantém o watch
98
98
  });
99
99
  }
@@ -1,15 +1,22 @@
1
1
  import { Logger } from "../utils/ui";
2
+ import { isWindows, getOpenBrowserArgs } from "../utils/platform";
2
3
 
3
4
  export class BrowserService {
4
5
  /**
5
- * Recarrega a aba ativa do browser (Chrome ou Edge) no Windows.
6
+ * Recarrega a aba ativa do browser (Chrome ou Edge).
7
+ * Nota: No Linux/Mac, esta funcionalidade é limitada devido às restrições
8
+ * de automação de GUI. Recomenda-se usar extensões de Live Reload.
6
9
  */
7
10
  public static async reload(url: string) {
8
- if (process.platform !== 'win32') return;
9
-
10
11
  // Pequeno delay para garantir que o Tomcat processou o novo contexto
11
12
  await new Promise(r => setTimeout(r, 800));
12
13
 
14
+ if (!isWindows()) {
15
+ // No Linux/Mac, tenta notificar via Browser Sync ou similar se disponível
16
+ // Por enquanto, apenas loga (o usuário pode usar extensões de Live Reload)
17
+ return;
18
+ }
19
+
13
20
  const psCommand = `
14
21
  $shell = New-Object -ComObject WScript.Shell
15
22
  $process = Get-Process | Where-Object { $_.MainWindowTitle -match "Chrome" -or $_.MainWindowTitle -match "Edge" } | Select-Object -First 1
@@ -31,11 +38,7 @@ export class BrowserService {
31
38
  * Abre a URL no browser padrão do sistema.
32
39
  */
33
40
  public static open(url: string) {
34
- if (process.platform === 'win32') {
35
- Bun.spawn(["cmd", "/c", "start", url]);
36
- } else {
37
- const start = process.platform === 'darwin' ? 'open' : 'xdg-open';
38
- Bun.spawn([start, url]);
39
- }
41
+ const args = getOpenBrowserArgs(url);
42
+ Bun.spawn(args);
40
43
  }
41
44
  }
@@ -30,6 +30,11 @@ export class BuildService {
30
30
  this.cache.clearCache();
31
31
  }
32
32
 
33
+ // Sempre limpa a pasta de build antes (target/ ou build/)
34
+ if (!incremental) {
35
+ await this.cleanBuildDirectory();
36
+ }
37
+
33
38
  // Cache só é usado se --cache for passado ou em modo incremental
34
39
  const useCache = this.projectConfig.cache || incremental;
35
40
 
@@ -54,7 +59,8 @@ export class BuildService {
54
59
  if (incremental) {
55
60
  command.push("compile");
56
61
  } else {
57
- if (this.projectConfig.clean) command.push("clean");
62
+ // Sempre executa clean antes do build
63
+ command.push("clean");
58
64
  // Use 'package' para gerar .war ou 'war:exploded' para pasta
59
65
  if (this.projectConfig.war) {
60
66
  command.push("package");
@@ -76,7 +82,8 @@ export class BuildService {
76
82
  if (incremental) {
77
83
  command.push("classes");
78
84
  } else {
79
- if (this.projectConfig.clean) command.push("clean");
85
+ // Sempre executa clean antes do build
86
+ command.push("clean");
80
87
  command.push("war");
81
88
  command.push("--parallel", "--build-cache");
82
89
  }
@@ -123,6 +130,26 @@ export class BuildService {
123
130
  }
124
131
  }
125
132
 
133
+ /**
134
+ * Limpa fisicamente o diretório de build (target/ ou build/)
135
+ * Garante build limpo antes de cada execução
136
+ */
137
+ private async cleanBuildDirectory(): Promise<void> {
138
+ const buildDir = this.projectConfig.buildTool === 'maven'
139
+ ? path.join(process.cwd(), 'target')
140
+ : path.join(process.cwd(), 'build');
141
+
142
+ if (existsSync(buildDir)) {
143
+ try {
144
+ Logger.step(`Cleaning ${path.basename(buildDir)}/ directory...`);
145
+ await fs.rm(buildDir, { recursive: true, force: true });
146
+ Logger.debug(`Removed ${buildDir}`);
147
+ } catch (e) {
148
+ Logger.warn(`Could not fully remove ${buildDir}, continuing...`);
149
+ }
150
+ }
151
+ }
152
+
126
153
  async syncExploded(srcDir: string, destDir: string): Promise<void> {
127
154
  if (!existsSync(srcDir)) return;
128
155
  if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
@@ -10,6 +10,17 @@ import {
10
10
  import path from "path";
11
11
  import os from "os";
12
12
  import { spawn } from "child_process";
13
+ import {
14
+ getPlatform,
15
+ isWindows,
16
+ getTomcatArchiveName,
17
+ getTomcatDownloadUrl,
18
+ getTomcatArchiveUrl,
19
+ getExtractCommand,
20
+ getPortCheckCommand,
21
+ getCatalinaScript,
22
+ hasCatalinaScript,
23
+ } from "../utils/platform";
13
24
 
14
25
  export interface EmbeddedTomcatOptions {
15
26
  version?: string;
@@ -35,20 +46,18 @@ export class EmbeddedTomcatService {
35
46
  private isInstalled: boolean = false;
36
47
 
37
48
  // Versões estáveis do Tomcat (atualizadas: 2026-03-04)
49
+ // URLs são construídas dinamicamente baseadas na plataforma
38
50
  private static readonly VERSIONS: Record<
39
51
  string,
40
- { url: string; sha512: string }
52
+ { sha512: string }
41
53
  > = {
42
54
  "10.1.52": {
43
- url: "https://dlcdn.apache.org/tomcat/tomcat-10/v10.1.52/bin/apache-tomcat-10.1.52-windows-x64.zip",
44
55
  sha512: "",
45
56
  },
46
57
  "9.0.115": {
47
- url: "https://dlcdn.apache.org/tomcat/tomcat-9/v9.0.115/bin/apache-tomcat-9.0.115-windows-x64.zip",
48
58
  sha512: "",
49
59
  },
50
60
  "11.0.18": {
51
- url: "https://dlcdn.apache.org/tomcat/tomcat-11/v11.0.18/bin/apache-tomcat-11.0.18-windows-x64.zip",
52
61
  sha512: "",
53
62
  },
54
63
  };
@@ -61,14 +70,14 @@ export class EmbeddedTomcatService {
61
70
  this.baseDir = path.join(os.homedir(), ".xavva", "tomcat");
62
71
  this.tomcatHome = path.join(this.baseDir, this.version);
63
72
 
64
- // Se a versão não está na lista, usa URL padrão
73
+ // Constrói URL de download baseada na plataforma
65
74
  const versionInfo = EmbeddedTomcatService.VERSIONS[this.version];
66
75
  if (versionInfo) {
67
- this.downloadUrl = versionInfo.url;
76
+ // Usa URL primária (CDN Apache)
77
+ this.downloadUrl = getTomcatDownloadUrl(this.version);
68
78
  } else {
69
- // Tenta inferir URL baseado no padrão Apache
70
- const majorVersion = this.version.split(".")[0];
71
- this.downloadUrl = `https://archive.apache.org/dist/tomcat/tomcat-${majorVersion}/v${this.version}/bin/apache-tomcat-${this.version}-windows-x64.zip`;
79
+ // Tenta inferir URL baseado no padrão Apache (archive)
80
+ this.downloadUrl = getTomcatArchiveUrl(this.version);
72
81
  }
73
82
  }
74
83
 
@@ -76,8 +85,7 @@ export class EmbeddedTomcatService {
76
85
  * Verifica se o Tomcat já está instalado
77
86
  */
78
87
  checkInstallation(): boolean {
79
- const catalinaBat = path.join(this.tomcatHome, "bin", "catalina.bat");
80
- this.isInstalled = existsSync(catalinaBat);
88
+ this.isInstalled = hasCatalinaScript(this.tomcatHome);
81
89
  return this.isInstalled;
82
90
  }
83
91
 
@@ -100,13 +108,8 @@ export class EmbeddedTomcatService {
100
108
 
101
109
  for (const entry of entries) {
102
110
  if (entry.isDirectory()) {
103
- const catalinaBat = path.join(
104
- baseDir,
105
- entry.name,
106
- "bin",
107
- "catalina.bat",
108
- );
109
- if (existsSync(catalinaBat)) {
111
+ const tomcatPath = path.join(baseDir, entry.name);
112
+ if (hasCatalinaScript(tomcatPath)) {
110
113
  versions.push(entry.name);
111
114
  }
112
115
  }
@@ -133,10 +136,8 @@ export class EmbeddedTomcatService {
133
136
  mkdirSync(this.baseDir, { recursive: true });
134
137
  }
135
138
 
136
- const zipPath = path.join(
137
- this.baseDir,
138
- `apache-tomcat-${this.version}.zip`,
139
- );
139
+ const archiveName = getTomcatArchiveName(this.version);
140
+ const zipPath = path.join(this.baseDir, archiveName);
140
141
 
141
142
  try {
142
143
  // Download
@@ -300,21 +301,24 @@ export class EmbeddedTomcatService {
300
301
  */
301
302
  async isPortAvailable(): Promise<boolean> {
302
303
  return new Promise((resolve) => {
303
- const netstat = spawn("cmd", [
304
- "/c",
305
- `netstat -ano | findstr :${this.port}`,
306
- ]);
304
+ const cmd = getPortCheckCommand(this.port);
305
+ const checkProcess = spawn(cmd[0], cmd.slice(1));
307
306
  let output = "";
308
307
 
309
- netstat.stdout?.on("data", (data) => {
308
+ checkProcess.stdout?.on("data", (data) => {
309
+ output += data.toString();
310
+ });
311
+
312
+ checkProcess.stderr?.on("data", (data) => {
310
313
  output += data.toString();
311
314
  });
312
315
 
313
- netstat.on("close", () => {
316
+ checkProcess.on("close", () => {
317
+ // Se houver output, a porta está em uso
314
318
  resolve(output.trim().length === 0);
315
319
  });
316
320
 
317
- netstat.on("error", () => {
321
+ checkProcess.on("error", () => {
318
322
  resolve(true); // Assume disponível se não conseguir verificar
319
323
  });
320
324
  });
@@ -382,18 +386,23 @@ export class EmbeddedTomcatService {
382
386
  }
383
387
 
384
388
  /**
385
- * Extrai arquivo ZIP usando PowerShell
389
+ * Extrai arquivo de arquivos (ZIP ou tar.gz)
386
390
  */
387
391
  private async extractZip(zipPath: string, destDir: string): Promise<void> {
388
392
  const spinner = Logger.spinner("Extraindo arquivos...");
389
393
 
390
394
  return new Promise((resolve, reject) => {
391
- const ps = spawn("powershell", [
392
- "-command",
393
- `Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force`,
394
- ]);
395
+ const cmd = getExtractCommand(zipPath, destDir);
396
+
397
+ if (!cmd) {
398
+ spinner(false);
399
+ reject(new Error(`Formato de arquivo não suportado: ${path.extname(zipPath)}`));
400
+ return;
401
+ }
402
+
403
+ const extractProcess = spawn(cmd[0], cmd.slice(1));
395
404
 
396
- ps.on("close", (code) => {
405
+ extractProcess.on("close", (code) => {
397
406
  if (code === 0) {
398
407
  spinner(true);
399
408
  resolve();
@@ -403,7 +412,7 @@ export class EmbeddedTomcatService {
403
412
  }
404
413
  });
405
414
 
406
- ps.on("error", (err) => {
415
+ extractProcess.on("error", (err) => {
407
416
  spinner(false);
408
417
  reject(err);
409
418
  });
@@ -77,7 +77,7 @@ export class LogAnalyzer {
77
77
  const isProject = this.projectPrefixes.some(p => trimmed.includes(p));
78
78
 
79
79
  if (isProject) {
80
- return ` ${Logger.C.bold}${Logger.C.yellow}${trimmed}${Logger.C.reset}`;
80
+ return ` ${Logger.C.bold}${Logger.C.warning}${trimmed}${Logger.C.reset}`;
81
81
  } else {
82
82
  return ` ${Logger.C.dim}${trimmed}${Logger.C.reset}`;
83
83
  }
@@ -100,7 +100,7 @@ export class LogAnalyzer {
100
100
 
101
101
  let color = Logger.C.primary;
102
102
  let symbol = "●";
103
- if (level === "WARN") { color = Logger.C.yellow; symbol = "▲"; }
103
+ if (level === "WARN") { color = Logger.C.warning; symbol = "▲"; }
104
104
  else if (level === "ERROR") { color = Logger.C.red; symbol = "✖"; }
105
105
 
106
106
  return `${color}${symbol} ${Logger.C.bold}Hotswap:${Logger.C.reset} ${msg}`;
@@ -113,7 +113,7 @@ export class LogAnalyzer {
113
113
 
114
114
  let color = Logger.C.dim;
115
115
  let symbol = "ℹ";
116
- if (label === "WARNING") { color = Logger.C.yellow; symbol = "▲"; }
116
+ if (label === "WARNING") { color = Logger.C.warning; symbol = "▲"; }
117
117
  else if (label === "SEVERE" || label === "ERROR") { color = Logger.C.red; symbol = "✖"; }
118
118
 
119
119
  msg = msg.replace(/^(org\.apache|com\.sun|java\..*?|org\.glassfish)\.[a-zA-Z0-9.]+\s/, "").trim();
@@ -129,7 +129,7 @@ export class LogAnalyzer {
129
129
 
130
130
  let color = Logger.C.dim;
131
131
  let symbol = "ℹ";
132
- if (label === "WARNING" || label === "WARN") { color = Logger.C.yellow; symbol = "▲"; }
132
+ if (label === "WARNING" || label === "WARN") { color = Logger.C.warning; symbol = "▲"; }
133
133
  else if (label === "SEVERE" || label === "ERROR") { color = Logger.C.red; symbol = "✖"; }
134
134
 
135
135
  msg = msg.replace(/^(org\.apache|com\.sun|java\..*?)\.[a-zA-Z0-9.]+\s/, "").trim();
@@ -5,6 +5,14 @@ import { ProjectService } from "./ProjectService";
5
5
  import { existsSync, mkdirSync, writeFileSync, statSync, promises as fs } from "fs";
6
6
  import path from "path";
7
7
  import os from "os";
8
+ import {
9
+ getCatalinaPath,
10
+ getMemoryCommand,
11
+ getKillCommand,
12
+ getPortCheckCommand,
13
+ getJavaPath,
14
+ isWindows,
15
+ } from "../utils/platform";
8
16
 
9
17
  export class TomcatService {
10
18
  private activeConfig: TomcatConfig;
@@ -25,23 +33,55 @@ export class TomcatService {
25
33
  async getMemoryUsage(): Promise<string> {
26
34
  if (!this.pid) return "0 MB";
27
35
  try {
28
- const { stdout } = Bun.spawnSync(["powershell", "-command", `(Get-Process -Id ${this.pid}).WorkingSet64 / 1MB`]);
36
+ const cmd = getMemoryCommand(this.pid);
37
+ if (!cmd) return "N/A";
38
+
39
+ const { stdout } = Bun.spawnSync(cmd);
29
40
  const mem = await new Response(stdout).text();
30
- return `${Math.round(parseFloat(mem))} MB`;
41
+ const memValue = parseFloat(mem.trim());
42
+
43
+ if (isNaN(memValue)) return "N/A";
44
+
45
+ // No Linux/macOS, ps retorna em KB, convertemos para MB
46
+ if (!isWindows()) {
47
+ return `${Math.round(memValue / 1024)} MB`;
48
+ }
49
+
50
+ return `${Math.round(memValue)} MB`;
31
51
  } catch (e) {
32
52
  return "N/A";
33
53
  }
34
54
  }
35
55
 
36
56
  async killConflict() {
37
- const { stdout } = Bun.spawnSync(["cmd", "/c", `netstat -ano | findstr :${this.activeConfig.port}`]);
57
+ const cmd = getPortCheckCommand(this.activeConfig.port);
58
+ const { stdout } = Bun.spawnSync(cmd);
38
59
  const output = await new Response(stdout).text();
39
60
 
40
61
  if (output) {
41
- const lines = output.trim().split('\n');
42
- const pid = lines[0].trim().split(/\s+/).pop();
43
- Logger.step(`Freeing port ${this.activeConfig.port}`);
44
- Bun.spawnSync(["taskkill", "/F", "/PID", pid]);
62
+ // Extrai PID do output
63
+ let pid: string | undefined;
64
+
65
+ if (isWindows()) {
66
+ // Windows: netstat output, última coluna é o PID
67
+ const lines = output.trim().split('\n');
68
+ pid = lines[0].trim().split(/\s+/).pop();
69
+ } else {
70
+ // Linux/Mac: tenta extrair PID de lsof, ss ou netstat
71
+ // lsof -i :port: formato tem PID na coluna 2
72
+ // ss -tlnp: tem pid=XXXX
73
+ // netstat -tlnp: tem /XXXX no final
74
+ const match = output.match(/\b(\d+)\b/) || // número isolado (lsof)
75
+ output.match(/pid=(\d+)/) || // ss format
76
+ output.match(/\/(\d+)/); // netstat format
77
+ if (match) pid = match[1];
78
+ }
79
+
80
+ if (pid) {
81
+ Logger.step(`Freeing port ${this.activeConfig.port}`);
82
+ const killCmd = getKillCommand(pid);
83
+ Bun.spawnSync(killCmd);
84
+ }
45
85
  }
46
86
  }
47
87
 
@@ -105,21 +145,54 @@ export class TomcatService {
105
145
 
106
146
  Logger.step("Downloading HotswapAgent v2.0.3 (Global)...");
107
147
  const url = "https://github.com/HotswapProjects/HotswapAgent/releases/download/RELEASE-2.0.3/hotswap-agent-2.0.3.jar";
108
- const response = await fetch(url);
109
- if (!response.ok) throw new Error(`Status: ${response.status}`);
148
+
149
+ Logger.debug(`URL: ${url}`);
150
+ Logger.debug(`Destino: ${agentPath}`);
151
+
152
+ const response = await fetch(url, {
153
+ redirect: "follow",
154
+ });
155
+
156
+ if (!response.ok) {
157
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
158
+ }
159
+
160
+ const contentLength = response.headers.get("content-length");
161
+ Logger.debug(`Content-Length: ${contentLength || "unknown"}`);
110
162
 
111
163
  const buffer = await response.arrayBuffer();
164
+ Logger.debug(`Downloaded: ${buffer.byteLength} bytes`);
165
+
166
+ if (buffer.byteLength < 1000) {
167
+ throw new Error(`Arquivo muito pequeno (${buffer.byteLength} bytes)`);
168
+ }
169
+
112
170
  writeFileSync(agentPath, Buffer.from(buffer));
171
+
172
+ // Verifica se foi escrito corretamente
173
+ const stats = statSync(agentPath);
174
+ Logger.debug(`Escrito: ${stats.size} bytes`);
175
+
113
176
  Logger.success("HotswapAgent v2.0.3 installed globally!");
114
177
  return agentPath;
115
- } catch (e) {
116
- Logger.warn("Falha ao baixar HotswapAgent. Usando hot swap padrão da JVM.");
178
+ } catch (e: any) {
179
+ Logger.warn(`Falha ao baixar HotswapAgent: ${e.message}`);
180
+ Logger.warn("Usando hot swap padrão da JVM.");
181
+
182
+ // Limpa arquivo parcial se existir
183
+ if (existsSync(agentPath)) {
184
+ try {
185
+ await fs.unlink(agentPath);
186
+ Logger.debug("Arquivo parcial removido");
187
+ } catch {}
188
+ }
189
+
117
190
  return null;
118
191
  }
119
192
  }
120
193
 
121
194
  async start(config: AppConfig, isWatching: boolean = false) {
122
- const binPath = `${this.activeConfig.path}\\bin\\catalina.bat`;
195
+ const binPath = getCatalinaPath(this.activeConfig.path);
123
196
  const args = (config.project.debug || isWatching) ? ["jpda", "run"] : ["run"];
124
197
 
125
198
  const catalinaOpts = [process.env.CATALINA_OPTS || ""];
@@ -134,7 +207,7 @@ export class TomcatService {
134
207
  javaBin = path.join(process.env.JAVA_HOME, "bin", "java.exe");
135
208
  }
136
209
 
137
- const javaVer = Bun.spawnSync([javaBin, "-version"]);
210
+ const javaVer = Bun.spawnSync([getJavaPath(), "-version"]);
138
211
  const output = (javaVer.stderr.toString() + javaVer.stdout.toString()).toLowerCase();
139
212
 
140
213
  if (output.includes("dcevm") || output.includes("jbr") || output.includes("trava")) {
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Platform Utilities - Helpers para detecção e adaptação multiplataforma
3
+ *
4
+ * Centraliza toda a lógica de diferenciação entre Windows, Linux e macOS.
5
+ */
6
+
7
+ import os from "os";
8
+ import path from "path";
9
+
10
+ export type Platform = "win32" | "linux" | "darwin";
11
+
12
+ /**
13
+ * Retorna a plataforma atual normalizada
14
+ */
15
+ export function getPlatform(): Platform {
16
+ const platform = process.platform;
17
+ if (platform === "win32") return "win32";
18
+ if (platform === "darwin") return "darwin";
19
+ return "linux";
20
+ }
21
+
22
+ /**
23
+ * Verifica se está rodando no Windows
24
+ */
25
+ export function isWindows(): boolean {
26
+ return getPlatform() === "win32";
27
+ }
28
+
29
+ /**
30
+ * Verifica se está rodando no Linux
31
+ */
32
+ export function isLinux(): boolean {
33
+ return getPlatform() === "linux";
34
+ }
35
+
36
+ /**
37
+ * Verifica se está rodando no macOS
38
+ */
39
+ export function isMacOS(): boolean {
40
+ return getPlatform() === "darwin";
41
+ }
42
+
43
+ /**
44
+ * Retorna a extensão de script apropriada (.bat para Windows, .sh para Unix)
45
+ */
46
+ export function getScriptExt(): string {
47
+ return isWindows() ? ".bat" : ".sh";
48
+ }
49
+
50
+ /**
51
+ * Retorna o nome do binário Java apropriado (java.exe para Windows, java para Unix)
52
+ */
53
+ export function getJavaBinary(): string {
54
+ return isWindows() ? "java.exe" : "java";
55
+ }
56
+
57
+ /**
58
+ * Retorna o caminho completo para o binário Java se JAVA_HOME estiver definido
59
+ */
60
+ export function getJavaPath(): string {
61
+ if (process.env.JAVA_HOME) {
62
+ const javaBin = path.join(process.env.JAVA_HOME, "bin", getJavaBinary());
63
+ return javaBin;
64
+ }
65
+ return getJavaBinary();
66
+ }
67
+
68
+ /**
69
+ * Retorna o script catalina apropriado (catalina.bat ou catalina.sh)
70
+ */
71
+ export function getCatalinaScript(): string {
72
+ return `catalina${getScriptExt()}`;
73
+ }
74
+
75
+ /**
76
+ * Retorna o script startup apropriado
77
+ */
78
+ export function getStartupScript(): string {
79
+ return `startup${getScriptExt()}`;
80
+ }
81
+
82
+ /**
83
+ * Retorna o script shutdown apropriado
84
+ */
85
+ export function getShutdownScript(): string {
86
+ return `shutdown${getScriptExt()}`;
87
+ }
88
+
89
+ /**
90
+ * Retorna o comando Maven apropriado
91
+ */
92
+ export function getMavenCommand(): string {
93
+ return isWindows() ? "mvn.cmd" : "mvn";
94
+ }
95
+
96
+ /**
97
+ * Retorna o comando Gradle apropriado
98
+ */
99
+ export function getGradleCommand(): string {
100
+ return isWindows() ? "gradle.bat" : "gradle";
101
+ }
102
+
103
+ /**
104
+ * Retorna a extensão de arquivo de download do Tomcat (.zip para Windows, .tar.gz para Unix)
105
+ */
106
+ export function getTomcatArchiveExt(): string {
107
+ return isWindows() ? ".zip" : ".tar.gz";
108
+ }
109
+
110
+ /**
111
+ * Retorna o nome do arquivo do Tomcat para download
112
+ */
113
+ export function getTomcatArchiveName(version: string): string {
114
+ const baseName = `apache-tomcat-${version}`;
115
+ if (isWindows()) {
116
+ return `${baseName}-windows-x64.zip`;
117
+ }
118
+ if (isMacOS()) {
119
+ // macOS usa a mesma versão do Linux (tar.gz)
120
+ return `${baseName}.tar.gz`;
121
+ }
122
+ return `${baseName}.tar.gz`;
123
+ }
124
+
125
+ /**
126
+ * Retorna a URL de download do Tomcat baseada na versão e plataforma
127
+ */
128
+ export function getTomcatDownloadUrl(version: string): string {
129
+ const majorVersion = version.split(".")[0];
130
+ const archiveName = getTomcatArchiveName(version);
131
+
132
+ // URL primária (CDN Apache)
133
+ return `https://dlcdn.apache.org/tomcat/tomcat-${majorVersion}/v${version}/bin/${archiveName}`;
134
+ }
135
+
136
+ /**
137
+ * Retorna a URL alternativa (archive) caso a primária falhe
138
+ */
139
+ export function getTomcatArchiveUrl(version: string): string {
140
+ const majorVersion = version.split(".")[0];
141
+ const archiveName = getTomcatArchiveName(version);
142
+
143
+ // URL alternativa (archive Apache)
144
+ return `https://archive.apache.org/dist/tomcat/tomcat-${majorVersion}/v${version}/bin/${archiveName}`;
145
+ }
146
+
147
+ /**
148
+ * Retorna o comando para extrair um arquivo
149
+ * Retorna null se não for possível determinar
150
+ */
151
+ export function getExtractCommand(archivePath: string, destDir: string): string[] | null {
152
+ if (isWindows()) {
153
+ // Windows: usa PowerShell para extrair
154
+ if (archivePath.endsWith(".zip")) {
155
+ return [
156
+ "powershell",
157
+ "-command",
158
+ `Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`,
159
+ ];
160
+ }
161
+ // Para .tar.gz no Windows (PowerShell 5.1+)
162
+ if (archivePath.endsWith(".tar.gz") || archivePath.endsWith(".tgz")) {
163
+ return [
164
+ "powershell",
165
+ "-command",
166
+ `tar -xzf '${archivePath}' -C '${destDir}'`,
167
+ ];
168
+ }
169
+ } else {
170
+ // Linux/Mac: usa tar
171
+ if (archivePath.endsWith(".tar.gz") || archivePath.endsWith(".tgz")) {
172
+ return ["tar", "-xzf", archivePath, "-C", destDir];
173
+ }
174
+ // Para .zip no Linux/Mac
175
+ if (archivePath.endsWith(".zip")) {
176
+ // Tenta unzip primeiro, depois tar
177
+ return ["unzip", "-q", "-o", archivePath, "-d", destDir];
178
+ }
179
+ }
180
+ return null;
181
+ }
182
+
183
+ /**
184
+ * Retorna o comando para verificar se uma porta está em uso
185
+ */
186
+ export function getPortCheckCommand(port: number): string[] {
187
+ if (isWindows()) {
188
+ return ["cmd", "/c", `netstat -ano | findstr :${port}`];
189
+ }
190
+ // Linux/Mac: tenta lsof, ss, ou netstat
191
+ // Preferência por lsof (mais comum)
192
+ return ["sh", "-c", `lsof -i :${port} 2>/dev/null || ss -tlnp 2>/dev/null | grep ':${port}' || netstat -tlnp 2>/dev/null | grep ':${port}'`];
193
+ }
194
+
195
+ /**
196
+ * Retorna o comando para matar um processo pelo PID
197
+ */
198
+ export function getKillCommand(pid: number | string): string[] {
199
+ if (isWindows()) {
200
+ return ["taskkill", "/F", "/PID", String(pid)];
201
+ }
202
+ return ["kill", "-9", String(pid)];
203
+ }
204
+
205
+ /**
206
+ * Retorna o comando para obter uso de memória de um processo
207
+ */
208
+ export function getMemoryCommand(pid: number): string[] | null {
209
+ if (isWindows()) {
210
+ return [
211
+ "powershell",
212
+ "-command",
213
+ `(Get-Process -Id ${pid}).WorkingSet64 / 1MB`,
214
+ ];
215
+ }
216
+ // Linux: /proc/<pid>/status tem VmRSS em kB
217
+ if (isLinux()) {
218
+ return ["sh", "-c", `cat /proc/${pid}/status 2>/dev/null | grep VmRSS | awk '{print int($2/1024)}'`];
219
+ }
220
+ // macOS: usa ps
221
+ if (isMacOS()) {
222
+ return ["ps", "-o", "rss=", "-p", String(pid)];
223
+ }
224
+ return null;
225
+ }
226
+
227
+ /**
228
+ * Retorna o comando para abrir uma URL no navegador padrão
229
+ */
230
+ export function getOpenBrowserCommand(): string {
231
+ if (isWindows()) {
232
+ return "start";
233
+ }
234
+ if (isMacOS()) {
235
+ return "open";
236
+ }
237
+ return "xdg-open";
238
+ }
239
+
240
+ /**
241
+ * Retorna o comando para abrir uma URL no navegador (array para spawn)
242
+ */
243
+ export function getOpenBrowserArgs(url: string): string[] {
244
+ const cmd = getOpenBrowserCommand();
245
+ if (isWindows()) {
246
+ // Windows: start "" "url" (o "" é necessário para títulos de janela)
247
+ return [cmd, "", url];
248
+ }
249
+ return [cmd, url];
250
+ }
251
+
252
+ /**
253
+ * Retorna o comando para encontrar um binário no PATH
254
+ */
255
+ export function getWhichCommand(binary: string): string[] {
256
+ if (isWindows()) {
257
+ return ["where", binary];
258
+ }
259
+ return ["which", binary];
260
+ }
261
+
262
+ /**
263
+ * Retorna o separador de classpath apropriado
264
+ */
265
+ export function getClasspathSeparator(): string {
266
+ return path.delimiter;
267
+ }
268
+
269
+ /**
270
+ * Normaliza um caminho para uso em classpath (converte backslash para forward slash)
271
+ */
272
+ export function normalizeClasspathPath(p: string): string {
273
+ return p.replace(/\\/g, "/");
274
+ }
275
+
276
+ /**
277
+ * Retorna o comando para extrair um WAR/JAR (usando jar ou unzip)
278
+ */
279
+ export function getWarExtractCommand(warPath: string, destDir: string): string[] {
280
+ // Tenta usar jar (disponível em qualquer JDK)
281
+ return ["jar", "xf", warPath];
282
+ }
283
+
284
+ /**
285
+ * Retorna a URL de download do JetBrains Runtime (JBR) com DCEVM
286
+ */
287
+ export function getJbrDownloadUrl(version: string = "21"): string {
288
+ // JBR 21 é a versão recomendada
289
+ if (isWindows()) {
290
+ return `https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-${version}.0.6-windows-x64-b895.97.tar.gz`;
291
+ }
292
+ if (isMacOS()) {
293
+ return `https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-${version}.0.6-osx-x64-b895.97.tar.gz`;
294
+ }
295
+ // Linux x64
296
+ return `https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-${version}.0.6-linux-x64-b895.97.tar.gz`;
297
+ }
298
+
299
+ /**
300
+ * Retorna o comando para extrair um .tar.gz
301
+ */
302
+ export function getTarExtractCommand(tarPath: string, destDir: string): string[] {
303
+ if (isWindows()) {
304
+ // Windows 10+ tem tar nativo via PowerShell
305
+ return ["powershell", "-command", `tar -xzf '${tarPath}' -C '${destDir}'`];
306
+ }
307
+ return ["tar", "-xzf", tarPath, "-C", destDir];
308
+ }
309
+
310
+ /**
311
+ * Constrói o caminho completo para o script catalina
312
+ */
313
+ export function getCatalinaPath(tomcatHome: string): string {
314
+ return path.join(tomcatHome, "bin", getCatalinaScript());
315
+ }
316
+
317
+ /**
318
+ * Verifica se o script catalina existe no diretório do Tomcat
319
+ */
320
+ export function hasCatalinaScript(tomcatHome: string): boolean {
321
+ const { existsSync } = require("fs");
322
+ return existsSync(getCatalinaPath(tomcatHome));
323
+ }