@archznn/xavva 2.6.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.
@@ -0,0 +1,243 @@
1
+ /**
2
+ * FileWatcher genérico
3
+ * Responsabilidade única: observar mudanças em arquivos
4
+ * Sem acoplamento com lógica de deploy
5
+ */
6
+
7
+ import { watch, type FSWatcher } from "fs";
8
+ import { Logger } from "../utils/ui";
9
+
10
+ export interface FileWatcherOptions {
11
+ recursive?: boolean;
12
+ debounceMs?: number;
13
+ coolingMs?: number;
14
+ ignoredPatterns?: RegExp[];
15
+ }
16
+
17
+ export interface FileChangeEvent {
18
+ eventType: "rename" | "change";
19
+ filename: string | null;
20
+ fullPath: string;
21
+ }
22
+
23
+ export type FileChangeHandler = (event: FileChangeEvent) => void | Promise<void>;
24
+
25
+ export class FileWatcher {
26
+ private watcher: FSWatcher | null = null;
27
+ private options: Required<FileWatcherOptions>;
28
+ private handlers: Map<string, FileChangeHandler> = new Map();
29
+ private debounceTimers: Map<string, Timer> = new Map();
30
+ private coolingFiles: Set<string> = new Set();
31
+ private isWatching = false;
32
+
33
+ private static readonly DEFAULT_OPTIONS: Required<FileWatcherOptions> = {
34
+ recursive: true,
35
+ debounceMs: 300,
36
+ coolingMs: 1000,
37
+ ignoredPatterns: [
38
+ /node_modules/,
39
+ /\.git/,
40
+ /target/,
41
+ /build/,
42
+ /\.xavva/,
43
+ /\.idea/,
44
+ /\.vscode/,
45
+ /dist/,
46
+ /out/,
47
+ ],
48
+ };
49
+
50
+ constructor(options: FileWatcherOptions = {}) {
51
+ this.options = { ...FileWatcher.DEFAULT_OPTIONS, ...options };
52
+ }
53
+
54
+ /**
55
+ * Registra um handler para um padrão específico
56
+ */
57
+ on(pattern: string | RegExp, handler: FileChangeHandler): () => void {
58
+ const key = pattern.toString();
59
+ this.handlers.set(key, handler);
60
+
61
+ // Retorna função para remover handler
62
+ return () => this.handlers.delete(key);
63
+ }
64
+
65
+ /**
66
+ * Registra handler para qualquer mudança
67
+ */
68
+ onAny(handler: FileChangeHandler): () => void {
69
+ return this.on("*", handler);
70
+ }
71
+
72
+ /**
73
+ * Inicia o watching
74
+ */
75
+ start(rootPath: string = process.cwd()): void {
76
+ if (this.isWatching) {
77
+ Logger.debug("FileWatcher já está rodando");
78
+ return;
79
+ }
80
+
81
+ this.watcher = watch(
82
+ rootPath,
83
+ { recursive: this.options.recursive },
84
+ (eventType, filename) => this.handleWatchEvent(eventType, filename)
85
+ );
86
+
87
+ this.isWatching = true;
88
+ Logger.debug(`FileWatcher iniciado em ${rootPath}`);
89
+ }
90
+
91
+ /**
92
+ * Para o watching
93
+ */
94
+ stop(): void {
95
+ if (this.watcher) {
96
+ this.watcher.close();
97
+ this.watcher = null;
98
+ }
99
+
100
+ // Limpa timers pendentes
101
+ this.debounceTimers.forEach(timer => clearTimeout(timer));
102
+ this.debounceTimers.clear();
103
+
104
+ this.isWatching = false;
105
+ Logger.debug("FileWatcher parado");
106
+ }
107
+
108
+ /**
109
+ * Verifica se está observando
110
+ */
111
+ isActive(): boolean {
112
+ return this.isWatching;
113
+ }
114
+
115
+ /**
116
+ * Processa evento do fs.watch
117
+ */
118
+ private handleWatchEvent(
119
+ eventType: "rename" | "change",
120
+ filename: string | null
121
+ ): void {
122
+ if (!filename) return;
123
+
124
+ // Ignora arquivos em cooling
125
+ if (this.coolingFiles.has(filename)) return;
126
+ this.addToCooling(filename);
127
+
128
+ // Ignora patterns definidos
129
+ if (this.isIgnored(filename)) return;
130
+
131
+ const fullPath = this.resolvePath(filename);
132
+ const event: FileChangeEvent = { eventType, filename, fullPath };
133
+
134
+ // Aplica debounce
135
+ this.debounce(filename, () => {
136
+ this.notifyHandlers(event);
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Adiciona arquivo ao período de cooling
142
+ */
143
+ private addToCooling(filename: string): void {
144
+ this.coolingFiles.add(filename);
145
+ setTimeout(() => {
146
+ this.coolingFiles.delete(filename);
147
+ }, this.options.coolingMs);
148
+ }
149
+
150
+ /**
151
+ * Verifica se arquivo deve ser ignorado
152
+ */
153
+ private isIgnored(filename: string): boolean {
154
+ return this.options.ignoredPatterns.some(pattern => pattern.test(filename));
155
+ }
156
+
157
+ /**
158
+ * Resolve caminho completo
159
+ */
160
+ private resolvePath(filename: string): string {
161
+ // Normaliza separadores de path
162
+ return filename.replace(/\\/g, "/");
163
+ }
164
+
165
+ /**
166
+ * Aplica debounce no handler
167
+ */
168
+ private debounce(key: string, fn: () => void): void {
169
+ const existing = this.debounceTimers.get(key);
170
+ if (existing) {
171
+ clearTimeout(existing);
172
+ }
173
+
174
+ const timer = setTimeout(() => {
175
+ this.debounceTimers.delete(key);
176
+ fn();
177
+ }, this.options.debounceMs);
178
+
179
+ this.debounceTimers.set(key, timer);
180
+ }
181
+
182
+ /**
183
+ * Notifica todos os handlers relevantes
184
+ */
185
+ private notifyHandlers(event: FileChangeEvent): void {
186
+ // Notifica handlers específicos
187
+ for (const [pattern, handler] of this.handlers) {
188
+ if (this.matchesPattern(event.filename, pattern)) {
189
+ try {
190
+ const result = handler(event);
191
+ if (result instanceof Promise) {
192
+ result.catch(err => {
193
+ Logger.debug(`Erro em handler de watch: ${err.message}`);
194
+ });
195
+ }
196
+ } catch (err) {
197
+ Logger.debug(`Erro em handler de watch: ${(err as Error).message}`);
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Verifica se filename match com pattern
205
+ */
206
+ private matchesPattern(filename: string | null, pattern: string): boolean {
207
+ if (!filename) return false;
208
+ if (pattern === "*") return true;
209
+
210
+ try {
211
+ const regex = new RegExp(pattern);
212
+ return regex.test(filename);
213
+ } catch {
214
+ // Se não for regex válido, trata como string simples
215
+ return filename.includes(pattern);
216
+ }
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Helper para criar watcher com configuração padrão de projetos Java
222
+ */
223
+ export function createJavaFileWatcher(): FileWatcher {
224
+ return new FileWatcher({
225
+ recursive: true,
226
+ debounceMs: 300,
227
+ coolingMs: 1000,
228
+ ignoredPatterns: [
229
+ /node_modules/,
230
+ /\.git/,
231
+ /target/,
232
+ /build/,
233
+ /\.xavva/,
234
+ /\.idea/,
235
+ /\.vscode/,
236
+ /dist/,
237
+ /out/,
238
+ /\.class$/,
239
+ /\.jar$/,
240
+ /\.war$/,
241
+ ],
242
+ });
243
+ }
@@ -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
+ }
@@ -1,5 +1,8 @@
1
- import type { TomcatConfig, AppConfig } from "../types/config";
1
+ import type { TomcatConfig, AppConfig } from "../types";
2
+ import { getHotswapAgentUrl, VERSIONS } from "../config/versions";
3
+ import { NetworkError } from "../errors/XavvaError";
2
4
  import { Logger } from "../utils/ui";
5
+ import { ProgressBar, ThemedSpinner } from "../utils/ProgressBar";
3
6
  import type { Subprocess } from "bun";
4
7
  import { ProjectService } from "./ProjectService";
5
8
  import { existsSync, mkdirSync, writeFileSync, statSync, promises as fs } from "fs";
@@ -21,6 +24,7 @@ export class TomcatService {
21
24
  public onReady?: () => void;
22
25
  private pid: number | null = null;
23
26
  private projectService: ProjectService | null = null;
27
+ private hasReadyBeenCalled: boolean = false;
24
28
 
25
29
  constructor(customConfig: TomcatConfig) {
26
30
  this.activeConfig = customConfig;
@@ -136,54 +140,82 @@ export class TomcatService {
136
140
 
137
141
  private async ensureHotswapAgent(): Promise<string | null> {
138
142
  const agentDir = path.join(os.homedir(), ".xavva", "agents");
139
- const agentPath = path.join(agentDir, "hotswap-agent-2.0.3.jar");
143
+ const agentPath = path.join(agentDir, `hotswap-agent-${VERSIONS.HOTSWAP_AGENT.VERSION}.jar`);
140
144
 
141
145
  if (existsSync(agentPath) && statSync(agentPath).size > 1000) return agentPath;
142
146
 
143
147
  try {
144
148
  if (!existsSync(agentDir)) mkdirSync(agentDir, { recursive: true });
145
149
 
146
- Logger.step("Downloading HotswapAgent v2.0.3 (Global)...");
147
- const url = "https://github.com/HotswapProjects/HotswapAgent/releases/download/RELEASE-2.0.3/hotswap-agent-2.0.3.jar";
148
-
149
- Logger.debug(`URL: ${url}`);
150
- Logger.debug(`Destino: ${agentPath}`);
151
-
152
- const response = await fetch(url, {
153
- redirect: "follow",
154
- });
150
+ const url = getHotswapAgentUrl();
151
+ const response = await fetch(url, { redirect: "follow" });
155
152
 
156
153
  if (!response.ok) {
157
154
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
158
155
  }
159
156
 
160
- const contentLength = response.headers.get("content-length");
161
- Logger.debug(`Content-Length: ${contentLength || "unknown"}`);
157
+ const totalSize = parseInt(response.headers.get("content-length") || "0");
162
158
 
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)`);
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);
168
202
  }
169
203
 
170
- writeFileSync(agentPath, Buffer.from(buffer));
171
-
172
204
  // Verifica se foi escrito corretamente
173
205
  const stats = statSync(agentPath);
174
- Logger.debug(`Escrito: ${stats.size} bytes`);
206
+ if (stats.size < 1000) {
207
+ throw new Error(`Arquivo muito pequeno (${stats.size} bytes)`);
208
+ }
175
209
 
176
- Logger.success("HotswapAgent v2.0.3 installed globally!");
210
+ Logger.success(`HotswapAgent v${VERSIONS.HOTSWAP_AGENT.VERSION} instalado!`);
177
211
  return agentPath;
178
212
  } catch (e: any) {
179
- Logger.warn(`Falha ao baixar HotswapAgent: ${e.message}`);
180
- Logger.warn("Usando hot swap padrão da JVM.");
213
+ Logger.warn("Falha ao baixar HotswapAgent. Usando hot swap padrão da JVM.");
181
214
 
182
215
  // Limpa arquivo parcial se existir
183
216
  if (existsSync(agentPath)) {
184
217
  try {
185
218
  await fs.unlink(agentPath);
186
- Logger.debug("Arquivo parcial removido");
187
219
  } catch {}
188
220
  }
189
221
 
@@ -331,7 +363,8 @@ export class TomcatService {
331
363
  this.stopStartupSpinner(isSuccess);
332
364
  this.stopStartupSpinner = undefined;
333
365
  }
334
- if (isSuccess && this.onReady) {
366
+ if (isSuccess && this.onReady && !this.hasReadyBeenCalled) {
367
+ this.hasReadyBeenCalled = true;
335
368
  this.onReady();
336
369
  }
337
370
  }