@archznn/xavva 2.7.0 → 2.8.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/src/index.ts CHANGED
@@ -23,7 +23,8 @@ async function main() {
23
23
  const commandNames = [
24
24
  "deploy", "build", "start", "dev", "doctor", "run",
25
25
  "debug", "logs", "docs", "audit", "profiles",
26
- "deps", "tomcat", "encoding"
26
+ "deps", "tomcat", "encoding", "init", "config",
27
+ "history", "redo", "health", "completion", "help"
27
28
  ];
28
29
  const commandName = positionals.find(p => commandNames.includes(p)) || "deploy";
29
30
 
@@ -96,15 +97,52 @@ async function main() {
96
97
  registry.register("encoding", commands.encoding);
97
98
  registry.register("deploy", commands.deploy);
98
99
  registry.register("dev", commands.dev);
100
+ registry.register("init", commands.init);
101
+ registry.register("config", commands.config);
102
+ registry.register("history", commands.history);
103
+ registry.register("redo", commands.redo);
104
+ registry.register("health", commands.health);
105
+ registry.register("completion", commands.completion);
99
106
 
100
107
  // Configura flags específicas
101
108
  if (commandName === "debug") values.debug = true;
102
109
  if (commandName === "run") values.debug = false;
103
110
 
111
+ // Registra comando no histórico antes da execução
112
+ const startTime = Date.now();
113
+ let success = true;
114
+
104
115
  try {
105
116
  await registry.execute(commandName, config, values as CLIArguments, positionals);
106
117
  } catch (error) {
118
+ success = false;
107
119
  await ErrorHandler.getInstance().handle(error, { phase: "command-execution", command: commandName });
120
+ } finally {
121
+ // Salva no histórico
122
+ const duration = (Date.now() - startTime) / 1000;
123
+ const filteredPositionals = positionals.filter(p => p !== commandName && !commandNames.includes(p));
124
+ services.historyService.add({
125
+ command: commandName,
126
+ args: [...filteredPositionals, ...Object.entries(values)
127
+ .filter(([, v]) => v !== undefined && typeof v !== "object")
128
+ .flatMap(([k, v]) => [`--${k}`, String(v)])],
129
+ success,
130
+ duration
131
+ }).catch(() => { /* ignore history errors */ });
132
+
133
+ // Envia notificação para comandos longos
134
+ if (duration > 5 && commandName !== "logs" && commandName !== "history") {
135
+ const { NotificationService } = await import("./services/NotificationService");
136
+ if (success) {
137
+ if (commandName === "build" || commandName === "deploy") {
138
+ NotificationService.buildSuccess(duration);
139
+ } else if (commandName === "start") {
140
+ NotificationService.deployComplete(config.project.appName);
141
+ }
142
+ } else {
143
+ NotificationService.buildFailed(`Comando ${commandName} falhou`);
144
+ }
145
+ }
108
146
  }
109
147
  }
110
148
  }
@@ -1,4 +1,5 @@
1
1
  import { Logger } from "../utils/ui";
2
+ import { ProgressBar, ThemedSpinner } from "../utils/ProgressBar";
2
3
  import { VERSIONS, getAvailableTomcatVersions, isSupportedTomcatVersion } from "../config/versions";
3
4
  import {
4
5
  existsSync,
@@ -347,27 +348,69 @@ export class EmbeddedTomcatService {
347
348
  * Download com progresso
348
349
  */
349
350
  private async downloadFile(url: string, destPath: string): Promise<void> {
350
- const spinner = Logger.spinner(`Baixando Tomcat ${this.version}...`);
351
+ const response = await fetch(url);
351
352
 
352
- try {
353
- const response = await fetch(url);
353
+ if (!response.ok) {
354
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
355
+ }
356
+
357
+ const totalSize = parseInt(response.headers.get("content-length") || "0");
358
+
359
+ // Se temos content-length, usar progress bar
360
+ if (totalSize > 0) {
361
+ const progress = new ProgressBar({
362
+ title: `Baixando Tomcat ${this.version}`,
363
+ total: totalSize,
364
+ width: 25
365
+ });
354
366
 
355
- if (!response.ok) {
356
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
367
+ const reader = response.body?.getReader();
368
+ if (!reader) {
369
+ throw new Error("Response body não disponível");
357
370
  }
358
371
 
359
- const totalSize = parseInt(response.headers.get("content-length") || "0");
360
- const buffer = await response.arrayBuffer();
372
+ const chunks: Uint8Array[] = [];
373
+ let received = 0;
361
374
 
362
- writeFileSync(destPath, Buffer.from(buffer));
375
+ while (true) {
376
+ const { done, value } = await reader.read();
377
+ if (done) break;
378
+
379
+ chunks.push(value);
380
+ received += value.length;
381
+ progress.update(received);
382
+ }
363
383
 
364
- spinner(true);
384
+ progress.complete();
365
385
 
366
- const sizeMB = (buffer.byteLength / 1024 / 1024).toFixed(1);
386
+ // Concatena chunks e salva
387
+ const allChunks = new Uint8Array(received);
388
+ let position = 0;
389
+ for (const chunk of chunks) {
390
+ allChunks.set(chunk, position);
391
+ position += chunk.length;
392
+ }
393
+
394
+ await fsPromises.writeFile(destPath, allChunks);
395
+
396
+ const sizeMB = (received / 1024 / 1024).toFixed(1);
367
397
  Logger.info("Download", `${sizeMB} MB baixados`);
368
- } catch (error) {
369
- spinner(false);
370
- throw error;
398
+ } else {
399
+ // Sem content-length, usar spinner temático
400
+ const spinner = new ThemedSpinner();
401
+ const stop = spinner.start(`Baixando Tomcat ${this.version}`, "dots", "download");
402
+
403
+ try {
404
+ const buffer = await response.arrayBuffer();
405
+ await fsPromises.writeFile(destPath, Buffer.from(buffer));
406
+ stop(true);
407
+
408
+ const sizeMB = (buffer.byteLength / 1024 / 1024).toFixed(1);
409
+ Logger.info("Download", `${sizeMB} MB baixados`);
410
+ } catch (error) {
411
+ stop(false);
412
+ throw error;
413
+ }
371
414
  }
372
415
  }
373
416
 
@@ -375,13 +418,14 @@ export class EmbeddedTomcatService {
375
418
  * Extrai arquivo de arquivos (ZIP ou tar.gz)
376
419
  */
377
420
  private async extractZip(zipPath: string, destDir: string): Promise<void> {
378
- const spinner = Logger.spinner("Extraindo arquivos...");
421
+ const spinner = new ThemedSpinner();
422
+ const stop = spinner.start("Extraindo arquivos", "pulse", "build");
379
423
 
380
424
  return new Promise((resolve, reject) => {
381
425
  const cmd = getExtractCommand(zipPath, destDir);
382
426
 
383
427
  if (!cmd) {
384
- spinner(false);
428
+ stop(false);
385
429
  reject(new Error(`Formato de arquivo não suportado: ${path.extname(zipPath)}`));
386
430
  return;
387
431
  }
@@ -390,16 +434,16 @@ export class EmbeddedTomcatService {
390
434
 
391
435
  extractProcess.on("close", (code) => {
392
436
  if (code === 0) {
393
- spinner(true);
437
+ stop(true);
394
438
  resolve();
395
439
  } else {
396
- spinner(false);
440
+ stop(false);
397
441
  reject(new Error(`Falha ao extrair (código ${code})`));
398
442
  }
399
443
  });
400
444
 
401
445
  extractProcess.on("error", (err) => {
402
- spinner(false);
446
+ stop(false);
403
447
  reject(err);
404
448
  });
405
449
  });
@@ -0,0 +1,73 @@
1
+ import { mkdir, readFile, writeFile } from "fs/promises";
2
+ import { existsSync } from "fs";
3
+ import { join } from "path";
4
+ import { homedir } from "os";
5
+
6
+ export interface HistoryEntry {
7
+ command: string;
8
+ args: string[];
9
+ timestamp: string;
10
+ success: boolean;
11
+ duration?: number;
12
+ }
13
+
14
+ const HISTORY_FILE = join(homedir(), ".xavva", "history.json");
15
+ const MAX_HISTORY_SIZE = 100;
16
+
17
+ export class HistoryService {
18
+ private async ensureDir(): Promise<void> {
19
+ const dir = join(homedir(), ".xavva");
20
+ if (!existsSync(dir)) {
21
+ await mkdir(dir, { recursive: true });
22
+ }
23
+ }
24
+
25
+ private async load(): Promise<HistoryEntry[]> {
26
+ try {
27
+ await this.ensureDir();
28
+ const data = await readFile(HISTORY_FILE, "utf-8");
29
+ return JSON.parse(data);
30
+ } catch {
31
+ return [];
32
+ }
33
+ }
34
+
35
+ private async save(entries: HistoryEntry[]): Promise<void> {
36
+ await this.ensureDir();
37
+ // Keep only last MAX_HISTORY_SIZE entries
38
+ const trimmed = entries.slice(-MAX_HISTORY_SIZE);
39
+ await writeFile(HISTORY_FILE, JSON.stringify(trimmed, null, 2));
40
+ }
41
+
42
+ async add(entry: Omit<HistoryEntry, "timestamp">): Promise<void> {
43
+ const entries = await this.load();
44
+ entries.push({
45
+ ...entry,
46
+ timestamp: new Date().toISOString()
47
+ });
48
+ await this.save(entries);
49
+ }
50
+
51
+ async getRecent(limit: number = 10): Promise<HistoryEntry[]> {
52
+ const entries = await this.load();
53
+ return entries.slice(-limit).reverse();
54
+ }
55
+
56
+ async getLast(): Promise<HistoryEntry | null> {
57
+ const entries = await this.load();
58
+ return entries.length > 0 ? entries[entries.length - 1] : null;
59
+ }
60
+
61
+ async clear(): Promise<void> {
62
+ await this.save([]);
63
+ }
64
+
65
+ async getStats(): Promise<{ total: number; successful: number; failed: number }> {
66
+ const entries = await this.load();
67
+ return {
68
+ total: entries.length,
69
+ successful: entries.filter(e => e.success).length,
70
+ failed: entries.filter(e => !e.success).length
71
+ };
72
+ }
73
+ }
@@ -0,0 +1,145 @@
1
+ import { exec } from "child_process";
2
+ import { promisify } from "util";
3
+ import { platform } from "os";
4
+
5
+ const execAsync = promisify(exec);
6
+
7
+ export interface NotificationOptions {
8
+ title: string;
9
+ message: string;
10
+ type?: "info" | "success" | "warning" | "error";
11
+ sound?: boolean;
12
+ }
13
+
14
+ export class NotificationService {
15
+ private static enabled = true;
16
+
17
+ static disable(): void {
18
+ this.enabled = false;
19
+ }
20
+
21
+ static enable(): void {
22
+ this.enabled = true;
23
+ }
24
+
25
+ static async notify(options: NotificationOptions): Promise<void> {
26
+ if (!this.enabled) return;
27
+
28
+ const { title, message, type = "info", sound = false } = options;
29
+
30
+ try {
31
+ if (platform() === "win32") {
32
+ await this.notifyWindows(title, message, type, sound);
33
+ } else if (platform() === "darwin") {
34
+ await this.notifyMacOS(title, message, type, sound);
35
+ } else {
36
+ await this.notifyLinux(title, message, type, sound);
37
+ }
38
+ } catch {
39
+ // Silently fail - notifications are not critical
40
+ }
41
+ }
42
+
43
+ private static async notifyWindows(
44
+ title: string,
45
+ message: string,
46
+ type: string,
47
+ sound: boolean
48
+ ): Promise<void> {
49
+ // Use PowerShell notification
50
+ const iconMap: Record<string, string> = {
51
+ info: "Information",
52
+ success: "Information",
53
+ warning: "Warning",
54
+ error: "Error"
55
+ };
56
+
57
+ const psScript = `
58
+ Add-Type -AssemblyName System.Windows.Forms
59
+ $notify = New-Object System.Windows.Forms.NotifyIcon
60
+ $notify.Icon = [System.Drawing.SystemIcons]::${iconMap[type]}
61
+ $notify.BalloonTipTitle = "${title.replace(/"/g, '""')}"
62
+ $notify.BalloonTipText = "${message.replace(/"/g, '""')}"
63
+ $notify.BalloonTipIcon = "${iconMap[type]}"
64
+ $notify.Visible = $true
65
+ $notify.ShowBalloonTip(5000)
66
+ `;
67
+
68
+ await execAsync(`powershell -Command "${psScript}"`);
69
+ }
70
+
71
+ private static async notifyMacOS(
72
+ title: string,
73
+ message: string,
74
+ _type: string,
75
+ sound: boolean
76
+ ): Promise<void> {
77
+ const soundFlag = sound ? '"\\"\\""' : "";
78
+ await execAsync(`osascript -e 'display notification "${message.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"${soundFlag}'`);
79
+ }
80
+
81
+ private static async notifyLinux(
82
+ title: string,
83
+ message: string,
84
+ type: string,
85
+ _sound: boolean
86
+ ): Promise<void> {
87
+ const urgencyMap: Record<string, string> = {
88
+ info: "normal",
89
+ success: "normal",
90
+ warning: "normal",
91
+ error: "critical"
92
+ };
93
+
94
+ const iconMap: Record<string, string> = {
95
+ info: "dialog-information",
96
+ success: "dialog-information",
97
+ warning: "dialog-warning",
98
+ error: "dialog-error"
99
+ };
100
+
101
+ try {
102
+ await execAsync(`notify-send -u ${urgencyMap[type]} -i ${iconMap[type]} "${title}" "${message}"`);
103
+ } catch {
104
+ // Fallback: try zenity
105
+ try {
106
+ await execAsync(`zenity --info --title="${title}" --text="${message}" --timeout=5`);
107
+ } catch {
108
+ // No notification available
109
+ }
110
+ }
111
+ }
112
+
113
+ // Convenience methods
114
+ static buildSuccess(duration: number): Promise<void> {
115
+ return this.notify({
116
+ title: "Xavva - Build Completo",
117
+ message: `Build finalizado com sucesso em ${duration.toFixed(1)}s`,
118
+ type: "success"
119
+ });
120
+ }
121
+
122
+ static buildFailed(error: string): Promise<void> {
123
+ return this.notify({
124
+ title: "Xavva - Build Falhou",
125
+ message: error.slice(0, 100),
126
+ type: "error"
127
+ });
128
+ }
129
+
130
+ static deployComplete(appName: string): Promise<void> {
131
+ return this.notify({
132
+ title: "Xavva - Deploy Completo",
133
+ message: `${appName} implantado com sucesso`,
134
+ type: "success"
135
+ });
136
+ }
137
+
138
+ static watchReady(): Promise<void> {
139
+ return this.notify({
140
+ title: "Xavva - Watch Mode",
141
+ message: "Monitorando alterações nos arquivos...",
142
+ type: "info"
143
+ });
144
+ }
145
+ }
@@ -2,6 +2,7 @@ import type { TomcatConfig, AppConfig } from "../types";
2
2
  import { getHotswapAgentUrl, VERSIONS } from "../config/versions";
3
3
  import { NetworkError } from "../errors/XavvaError";
4
4
  import { Logger } from "../utils/ui";
5
+ import { ProgressBar, ThemedSpinner } from "../utils/ProgressBar";
5
6
  import type { Subprocess } from "bun";
6
7
  import { ProjectService } from "./ProjectService";
7
8
  import { existsSync, mkdirSync, writeFileSync, statSync, promises as fs } from "fs";
@@ -146,47 +147,75 @@ export class TomcatService {
146
147
  try {
147
148
  if (!existsSync(agentDir)) mkdirSync(agentDir, { recursive: true });
148
149
 
149
- Logger.step("Downloading HotswapAgent v2.0.3 (Global)...");
150
- const url = "https://github.com/HotswapProjects/HotswapAgent/releases/download/RELEASE-2.0.3/hotswap-agent-2.0.3.jar";
151
-
152
- Logger.debug(`URL: ${url}`);
153
- Logger.debug(`Destino: ${agentPath}`);
154
-
155
- const response = await fetch(url, {
156
- redirect: "follow",
157
- });
150
+ const url = getHotswapAgentUrl();
151
+ const response = await fetch(url, { redirect: "follow" });
158
152
 
159
153
  if (!response.ok) {
160
154
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
161
155
  }
162
156
 
163
- const contentLength = response.headers.get("content-length");
164
- Logger.debug(`Content-Length: ${contentLength || "unknown"}`);
157
+ const totalSize = parseInt(response.headers.get("content-length") || "0");
165
158
 
166
- const buffer = await response.arrayBuffer();
167
- Logger.debug(`Downloaded: ${buffer.byteLength} bytes`);
168
-
169
- if (buffer.byteLength < 1000) {
170
- throw new Error(`Arquivo muito pequeno (${buffer.byteLength} bytes)`);
159
+ if (totalSize > 0) {
160
+ // Usar progress bar
161
+ const progress = new ProgressBar({
162
+ title: `Baixando HotswapAgent v${VERSIONS.HOTSWAP_AGENT.VERSION}`,
163
+ total: totalSize,
164
+ width: 25
165
+ });
166
+
167
+ const reader = response.body?.getReader();
168
+ if (!reader) throw new Error("Response body não disponível");
169
+
170
+ const chunks: Uint8Array[] = [];
171
+ let received = 0;
172
+
173
+ while (true) {
174
+ const { done, value } = await reader.read();
175
+ if (done) break;
176
+
177
+ chunks.push(value);
178
+ received += value.length;
179
+ progress.update(received);
180
+ }
181
+
182
+ progress.complete();
183
+
184
+ // Concatena e salva
185
+ const allChunks = new Uint8Array(received);
186
+ let position = 0;
187
+ for (const chunk of chunks) {
188
+ allChunks.set(chunk, position);
189
+ position += chunk.length;
190
+ }
191
+
192
+ await fs.writeFile(agentPath, allChunks);
193
+ } else {
194
+ // Sem content-length, usar spinner
195
+ const spinner = new ThemedSpinner();
196
+ const stop = spinner.start(`Baixando HotswapAgent v${VERSIONS.HOTSWAP_AGENT.VERSION}`, "dots", "download");
197
+
198
+ const buffer = await response.arrayBuffer();
199
+ writeFileSync(agentPath, Buffer.from(buffer));
200
+
201
+ stop(true);
171
202
  }
172
203
 
173
- writeFileSync(agentPath, Buffer.from(buffer));
174
-
175
204
  // Verifica se foi escrito corretamente
176
205
  const stats = statSync(agentPath);
177
- Logger.debug(`Escrito: ${stats.size} bytes`);
206
+ if (stats.size < 1000) {
207
+ throw new Error(`Arquivo muito pequeno (${stats.size} bytes)`);
208
+ }
178
209
 
179
- Logger.success(`HotswapAgent v${VERSIONS.HOTSWAP_AGENT.VERSION} installed globally!`);
210
+ Logger.success(`HotswapAgent v${VERSIONS.HOTSWAP_AGENT.VERSION} instalado!`);
180
211
  return agentPath;
181
212
  } catch (e: any) {
182
- throw new NetworkError(url, e);
183
- Logger.warn("Usando hot swap padrão da JVM.");
213
+ Logger.warn("Falha ao baixar HotswapAgent. Usando hot swap padrão da JVM.");
184
214
 
185
215
  // Limpa arquivo parcial se existir
186
216
  if (existsSync(agentPath)) {
187
217
  try {
188
218
  await fs.unlink(agentPath);
189
- Logger.debug("Arquivo parcial removido");
190
219
  } catch {}
191
220
  }
192
221
 
package/src/types/args.ts CHANGED
@@ -109,6 +109,21 @@ export interface EncodingCommandArgs extends BaseArgs, EncodingArgs {
109
109
  // Herda de EncodingArgs
110
110
  }
111
111
 
112
+ // ===== Args dos Novos Comandos UX =====
113
+ export interface ConfigArgs extends BaseArgs {
114
+ interactive?: boolean;
115
+ i?: boolean;
116
+ }
117
+
118
+ export interface HistoryArgs extends BaseArgs {
119
+ clear?: boolean;
120
+ limit?: string;
121
+ }
122
+
123
+ export interface CompletionArgs extends BaseArgs {
124
+ shell?: string;
125
+ }
126
+
112
127
  // ===== CLIArguments Legado (para compatibilidade) =====
113
128
  // Será gradualmente removido
114
129
  export interface CLIArguments extends