@laphilosophia/steady-watch 2.0.3 → 2.1.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/dist/index.mjs CHANGED
@@ -44,7 +44,7 @@ function getTheme(name) {
44
44
  }
45
45
 
46
46
  // src/watcher.ts
47
- import { spawn } from "child_process";
47
+ import { execFile, spawn } from "child_process";
48
48
  import chokidar from "chokidar";
49
49
  import crypto from "crypto";
50
50
  import fs from "fs";
@@ -54,13 +54,19 @@ var _SteadyWatcher = class _SteadyWatcher extends EventEmitter {
54
54
  constructor(options) {
55
55
  super();
56
56
  this.watcher = null;
57
+ this.watcherReady = false;
57
58
  this.fileHashes = /* @__PURE__ */ new Map();
59
+ this.gitChangedFiles = /* @__PURE__ */ new Set();
58
60
  this.timeout = null;
59
- this.isRunning = false;
60
- this.activeProcess = null;
61
- this.killTimer = null;
61
+ this.cycleRunning = false;
62
+ this.pendingCycle = false;
63
+ this.lifecycleProcess = null;
64
+ this.lifecycleKillTimer = null;
65
+ this.startedProcess = null;
66
+ this.stoppingStartedProcess = null;
62
67
  this.retryCount = 0;
63
68
  this.disposed = false;
69
+ this.closePromise = null;
64
70
  this.options = this.normalizeOptions(options);
65
71
  this.t = getTheme(this.options.theme);
66
72
  }
@@ -71,15 +77,34 @@ var _SteadyWatcher = class _SteadyWatcher extends EventEmitter {
71
77
  ..._SteadyWatcher.DEFAULT_IGNORE,
72
78
  ...this.normalizeIgnorePatterns(options.ignore || [])
73
79
  ];
80
+ const start = options.start || "";
81
+ const hooks = {
82
+ ...options.hooks || {},
83
+ beforeCommand: options.beforeCommand || options.hooks?.beforeCommand,
84
+ afterCommand: options.afterCommand || options.hooks?.afterCommand,
85
+ beforeStop: options.beforeStop || options.hooks?.beforeStop,
86
+ afterStop: options.afterStop || options.hooks?.afterStop,
87
+ beforeStart: options.beforeStart || options.hooks?.beforeStart,
88
+ afterStart: options.afterStart || options.hooks?.afterStart,
89
+ onSkip: options.onSkip || options.hooks?.onSkip
90
+ };
74
91
  return {
75
92
  pattern: options.pattern,
76
- cmd: options.cmd,
93
+ cmd: options.cmd || "",
94
+ start,
95
+ restartOnSuccess: options.restartOnSuccess ?? false,
96
+ restartOnChange: options.restartOnChange ?? false,
97
+ initialRun: options.initialRun ?? Boolean(start),
98
+ hooks,
77
99
  delay: Math.max(0, options.delay ?? 300),
78
100
  verbose: options.verbose ?? false,
79
101
  quiet: options.quiet ?? false,
80
102
  ignore: mergedIgnore,
81
103
  ext: options.ext || [],
82
- killTimeout: Math.max(0, options.killTimeout ?? 0),
104
+ gitChanged: options.gitChanged ?? false,
105
+ gitBase: options.gitBase || "HEAD",
106
+ killTimeout: Math.max(0, options.killTimeout ?? (start ? 5e3 : 0)),
107
+ restartDelay: Math.max(0, options.restartDelay ?? 0),
83
108
  retry: Math.max(0, options.retry ?? 0),
84
109
  hash,
85
110
  mtimeOnly: options.mtimeOnly ?? false,
@@ -103,8 +128,23 @@ var _SteadyWatcher = class _SteadyWatcher extends EventEmitter {
103
128
  if (!this.options.pattern) {
104
129
  errors.push("Pattern is required");
105
130
  }
106
- if (!this.options.cmd) {
107
- errors.push("Command is required");
131
+ if (!this.options.cmd && !this.options.start) {
132
+ errors.push("Command is required. Use --cmd, or use --start with --restart-on-change");
133
+ }
134
+ if (this.options.start && !this.options.restartOnSuccess && !this.options.restartOnChange) {
135
+ errors.push("A start command requires --restart-on-success or --restart-on-change");
136
+ }
137
+ if (this.options.restartOnSuccess && (!this.options.cmd || !this.options.start)) {
138
+ errors.push("--restart-on-success requires both --cmd and --start");
139
+ }
140
+ if (this.options.restartOnChange && !this.options.start) {
141
+ errors.push("--restart-on-change requires --start");
142
+ }
143
+ if (this.options.restartOnChange && this.options.cmd) {
144
+ errors.push("--restart-on-change cannot be combined with --cmd; use --restart-on-success");
145
+ }
146
+ if (!this.options.cmd && this.options.start && !this.options.restartOnChange) {
147
+ errors.push("--restart-on-change is required when --start is used without --cmd");
108
148
  }
109
149
  if (this.options.delay < 0) {
110
150
  errors.push("Delay must be a non-negative number");
@@ -112,6 +152,12 @@ var _SteadyWatcher = class _SteadyWatcher extends EventEmitter {
112
152
  if (this.options.killTimeout < 0) {
113
153
  errors.push("Kill timeout must be a non-negative number");
114
154
  }
155
+ if (this.options.restartDelay < 0) {
156
+ errors.push("Restart delay must be a non-negative number");
157
+ }
158
+ if (this.options.gitChanged && !this.options.gitBase) {
159
+ errors.push("Git base must be set when git-changed mode is enabled");
160
+ }
115
161
  if (this.options.retry < 0) {
116
162
  errors.push("Retry must be a non-negative number");
117
163
  }
@@ -138,6 +184,36 @@ var _SteadyWatcher = class _SteadyWatcher extends EventEmitter {
138
184
  return null;
139
185
  }
140
186
  }
187
+ normalizeGitPath(filePath) {
188
+ return path.relative(process.cwd(), path.resolve(filePath)).replace(/\\/g, "/");
189
+ }
190
+ runGit(args) {
191
+ return new Promise((resolve, reject) => {
192
+ execFile("git", args, { cwd: process.cwd(), maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
193
+ if (error) {
194
+ const message = stderr.trim() || error.message;
195
+ reject(new Error(`git ${args.join(" ")} failed: ${message}`));
196
+ return;
197
+ }
198
+ resolve(stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean));
199
+ });
200
+ });
201
+ }
202
+ async refreshGitChangedFiles() {
203
+ if (!this.options.gitChanged) return;
204
+ const [unstaged, staged, untracked] = await Promise.all([
205
+ this.runGit(["diff", "--name-only", "--relative", this.options.gitBase, "--"]),
206
+ this.runGit(["diff", "--name-only", "--cached", "--relative", this.options.gitBase, "--"]),
207
+ this.runGit(["ls-files", "--others", "--exclude-standard"])
208
+ ]);
209
+ this.gitChangedFiles = new Set([...unstaged, ...staged, ...untracked].map((file) => file.replace(/\\/g, "/")));
210
+ }
211
+ async isGitChangedFile(filePath) {
212
+ if (!this.options.gitChanged) return true;
213
+ await this.refreshGitChangedFiles();
214
+ const relativePath = this.normalizeGitPath(filePath);
215
+ return this.gitChangedFiles.has(relativePath);
216
+ }
141
217
  log(...args) {
142
218
  if (!this.options.quiet && !this.disposed) console.log(...args);
143
219
  }
@@ -154,6 +230,33 @@ var _SteadyWatcher = class _SteadyWatcher extends EventEmitter {
154
230
  timestamp() {
155
231
  return this.t.gray(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}]`);
156
232
  }
233
+ async runHook(hook, context, mode) {
234
+ const callback = this.options.hooks[hook];
235
+ if (!callback) return true;
236
+ const hookContext = {
237
+ phase: hook,
238
+ timestamp: /* @__PURE__ */ new Date(),
239
+ ...context
240
+ };
241
+ try {
242
+ await callback(Object.freeze(hookContext));
243
+ return true;
244
+ } catch (error) {
245
+ const err = error instanceof Error ? error : new Error(String(error));
246
+ this.log(this.t.red(`Hook ${hook} failed: ${err.message}`));
247
+ this.logJson("hook_error", { hook, error: err.message });
248
+ this.emit("hookError", hook, err);
249
+ if (mode === "notify") return true;
250
+ this.emit("fail", null);
251
+ return false;
252
+ }
253
+ }
254
+ notifyHook(hook, context) {
255
+ void this.runHook(hook, context, "notify");
256
+ }
257
+ sleep(ms) {
258
+ return new Promise((resolve) => setTimeout(resolve, ms));
259
+ }
157
260
  parseCommand(cmdString) {
158
261
  const tokens = [];
159
262
  let current = "";
@@ -162,7 +265,6 @@ var _SteadyWatcher = class _SteadyWatcher extends EventEmitter {
162
265
  let escaped = false;
163
266
  for (let i = 0; i < cmdString.length; i++) {
164
267
  const char = cmdString[i];
165
- const prevChar = i > 0 ? cmdString[i - 1] : "";
166
268
  if (escaped) {
167
269
  current += char;
168
270
  escaped = false;
@@ -192,92 +294,380 @@ var _SteadyWatcher = class _SteadyWatcher extends EventEmitter {
192
294
  }
193
295
  return { cmd: tokens[0] || "", args: tokens.slice(1) };
194
296
  }
195
- runCommand() {
196
- if (this.disposed) return;
197
- if (this.isRunning) {
198
- this.logVerbose(this.t.yellow("\u26A0\uFE0F Previous command still running, skipping..."));
199
- this.emit("skip", "previous command still running");
200
- return;
297
+ commandNeedsShell(command) {
298
+ return /(?:&&|\|\||[|<>])/.test(command);
299
+ }
300
+ resolveWindowsCommand(command) {
301
+ if (process.platform !== "win32") return null;
302
+ if (path.isAbsolute(command) || command.includes("\\") || command.includes("/")) {
303
+ return fs.existsSync(command) ? command : null;
201
304
  }
202
- if (this.options.clearScreen) {
203
- console.clear();
305
+ const pathEntries = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
306
+ const pathExts = (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
307
+ const hasExt = Boolean(path.extname(command));
308
+ const candidates = hasExt ? [command] : pathExts.map((ext) => `${command}${ext.toLowerCase()}`);
309
+ for (const entry of pathEntries) {
310
+ for (const candidate of candidates) {
311
+ const fullPath = path.join(entry, candidate);
312
+ if (fs.existsSync(fullPath)) return fullPath;
313
+ }
204
314
  }
205
- this.isRunning = true;
206
- const retryInfo = this.retryCount > 0 ? ` (Retry ${this.retryCount}/${this.options.retry})` : "";
207
- this.log(`${this.timestamp()} ${this.t.cyan("\u{1F680} Triggering:")} ${this.t.bold(this.options.cmd)}${retryInfo}`);
208
- this.logJson("trigger", { command: this.options.cmd, retry: this.retryCount });
209
- this.emit("trigger", this.options.cmd);
210
- const { cmd, args } = this.parseCommand(this.options.cmd);
211
- const startTime = Date.now();
212
- this.activeProcess = spawn(cmd, args, {
315
+ return null;
316
+ }
317
+ shouldUseShell(command, preferShell) {
318
+ if (preferShell || this.commandNeedsShell(command)) return true;
319
+ const parsed = this.parseCommand(command);
320
+ if (!parsed.cmd) return true;
321
+ if (process.platform !== "win32") return false;
322
+ const resolved = this.resolveWindowsCommand(parsed.cmd);
323
+ if (!resolved) return true;
324
+ return /\.(cmd|bat)$/i.test(resolved);
325
+ }
326
+ spawnCommand(command, preferShell) {
327
+ const useShell = this.shouldUseShell(command, preferShell);
328
+ const parsed = this.parseCommand(command);
329
+ const child = useShell ? spawn(command, {
213
330
  stdio: "inherit",
214
331
  shell: true,
215
- env: { ...process.env }
332
+ env: { ...process.env },
333
+ detached: process.platform !== "win32",
334
+ windowsHide: false
335
+ }) : spawn(parsed.cmd, parsed.args, {
336
+ stdio: "inherit",
337
+ env: { ...process.env },
338
+ detached: process.platform !== "win32",
339
+ windowsHide: false
216
340
  });
217
- if (this.options.killTimeout > 0) {
218
- this.killTimer = setTimeout(() => {
219
- if (this.activeProcess && !this.disposed) {
220
- this.log(this.t.yellow(`\u26A0\uFE0F Process timeout (${this.options.killTimeout}ms), force killing...`));
221
- this.activeProcess.kill("SIGKILL");
222
- }
223
- }, this.options.killTimeout);
224
- }
225
- this.activeProcess.on("error", (err) => {
226
- this.log(this.t.red(`Process error: ${err.message}`));
227
- this.emit("error", err);
341
+ return Object.assign(child, { steadyWatchUsesShell: useShell });
342
+ }
343
+ runTaskkill(pid, force) {
344
+ return new Promise((resolve) => {
345
+ const args = ["/pid", String(pid), "/T"];
346
+ if (force) args.push("/F");
347
+ const killer = spawn("taskkill", args, {
348
+ stdio: "ignore",
349
+ windowsHide: true
350
+ });
351
+ let settled = false;
352
+ const finish = (ok) => {
353
+ if (settled) return;
354
+ settled = true;
355
+ resolve(ok);
356
+ };
357
+ killer.on("close", (code) => finish(code === 0));
358
+ killer.on("error", () => finish(false));
228
359
  });
229
- this.activeProcess.on("close", (code, signal) => {
230
- if (this.disposed) return;
231
- this.isRunning = false;
232
- if (this.killTimer) {
233
- clearTimeout(this.killTimer);
234
- this.killTimer = null;
235
- }
236
- this.activeProcess = null;
237
- const duration = ((Date.now() - startTime) / 1e3).toFixed(2);
238
- if (code === 0) {
239
- this.log(`${this.timestamp()} ${this.t.green("\u2714 Done")} in ${duration}s`);
240
- this.logJson("done", { duration: parseFloat(duration) });
241
- this.emit("done", parseFloat(duration));
242
- this.retryCount = 0;
360
+ }
361
+ async forceKillProcess(child, label) {
362
+ if (process.platform === "win32" && child.pid) {
363
+ const killedTree = await this.runTaskkill(child.pid, true);
364
+ if (killedTree) return;
365
+ }
366
+ try {
367
+ if (process.platform !== "win32" && child.pid) {
368
+ process.kill(-child.pid, "SIGKILL");
243
369
  } else {
244
- const exitMessage = signal ? ` (Signal: ${signal})` : ` (Exit code: ${code})`;
245
- this.log(`${this.timestamp()} ${this.t.red("\u2718 Failed")}${exitMessage}`);
246
- this.logJson("failed", { exitCode: code, signal });
247
- this.emit("fail", code, signal ?? void 0);
248
- if (this.options.retry > 0 && this.retryCount < this.options.retry) {
249
- this.retryCount++;
250
- this.log(this.t.yellow(`\u{1F504} Retrying in 1s... (${this.retryCount}/${this.options.retry})`));
251
- setTimeout(() => this.runCommand(), 1e3);
370
+ child.kill("SIGKILL");
371
+ }
372
+ } catch {
373
+ this.logVerbose(this.t.gray(`Process already exited before force kill: ${label}`));
374
+ }
375
+ }
376
+ terminateProcess(child, command, label) {
377
+ return new Promise((resolve) => {
378
+ let settled = false;
379
+ const timeoutMs = this.options.killTimeout > 0 ? this.options.killTimeout : 5e3;
380
+ let forceTimer = null;
381
+ const finish = () => {
382
+ if (settled) return;
383
+ settled = true;
384
+ if (forceTimer) clearTimeout(forceTimer);
385
+ resolve();
386
+ };
387
+ if (child.exitCode !== null || child.signalCode !== null) {
388
+ finish();
389
+ return;
390
+ }
391
+ child.once("close", finish);
392
+ this.emit("stop", command, "SIGTERM");
393
+ const requestGracefulStop = async () => {
394
+ try {
395
+ if (process.platform === "win32" && child.pid) {
396
+ const killedTree = await this.runTaskkill(child.pid, false);
397
+ if (killedTree) {
398
+ finish();
399
+ return;
400
+ }
401
+ }
402
+ if (process.platform !== "win32" && child.pid) {
403
+ process.kill(-child.pid, "SIGTERM");
404
+ } else {
405
+ child.kill("SIGTERM");
406
+ }
407
+ } catch {
408
+ finish();
252
409
  return;
253
410
  }
411
+ };
412
+ void requestGracefulStop();
413
+ forceTimer = setTimeout(() => {
414
+ if (!settled) {
415
+ this.log(this.t.yellow(`Process did not exit after ${timeoutMs}ms, force killing ${label}...`));
416
+ void (async () => {
417
+ await this.forceKillProcess(child, label);
418
+ finish();
419
+ })();
420
+ }
421
+ }, timeoutMs);
422
+ });
423
+ }
424
+ runSingleCommand(attempt) {
425
+ if (this.disposed) {
426
+ return Promise.resolve({ code: null, signal: null, duration: 0 });
427
+ }
428
+ this.retryCount = attempt;
429
+ const retryInfo = attempt > 0 ? ` (Retry ${attempt}/${this.options.retry})` : "";
430
+ this.log(`${this.timestamp()} ${this.t.cyan("Triggering:")} ${this.t.bold(this.options.cmd)}${retryInfo}`);
431
+ this.logJson("trigger", { command: this.options.cmd, retry: attempt });
432
+ this.emit("trigger", this.options.cmd);
433
+ const startTime = Date.now();
434
+ const child = this.spawnCommand(this.options.cmd, false);
435
+ this.lifecycleProcess = child;
436
+ const commandTimeout = this.options.killTimeout;
437
+ if (commandTimeout > 0) {
438
+ this.lifecycleKillTimer = setTimeout(() => {
439
+ if (this.lifecycleProcess === child && !this.disposed) {
440
+ this.log(this.t.yellow(`Process timeout (${commandTimeout}ms), force killing...`));
441
+ void this.forceKillProcess(child, this.options.cmd);
442
+ }
443
+ }, commandTimeout);
444
+ }
445
+ return new Promise((resolve) => {
446
+ let settled = false;
447
+ const finish = (code, signal) => {
448
+ if (settled) return;
449
+ settled = true;
450
+ if (this.lifecycleKillTimer) {
451
+ clearTimeout(this.lifecycleKillTimer);
452
+ this.lifecycleKillTimer = null;
453
+ }
454
+ if (this.lifecycleProcess === child) {
455
+ this.lifecycleProcess = null;
456
+ }
457
+ const duration = (Date.now() - startTime) / 1e3;
458
+ if (!this.disposed) {
459
+ if (code === 0) {
460
+ this.log(`${this.timestamp()} ${this.t.green("Done")} in ${duration.toFixed(2)}s`);
461
+ this.logJson("done", { duration: parseFloat(duration.toFixed(2)) });
462
+ this.emit("done", parseFloat(duration.toFixed(2)));
463
+ } else {
464
+ const exitMessage = signal ? ` (Signal: ${signal})` : ` (Exit code: ${code})`;
465
+ this.log(`${this.timestamp()} ${this.t.red("Failed")}${exitMessage}`);
466
+ this.logJson("failed", { exitCode: code, signal });
467
+ this.emit("fail", code, signal ?? void 0);
468
+ }
469
+ }
470
+ resolve({ code, signal, duration });
471
+ };
472
+ child.on("error", (err) => {
473
+ if (!this.disposed) {
474
+ this.log(this.t.red(`Process error: ${err.message}`));
475
+ }
476
+ finish(null, null);
477
+ });
478
+ child.on("close", finish);
479
+ });
480
+ }
481
+ async runCommandWithRetry() {
482
+ let attempt = 0;
483
+ while (!this.disposed) {
484
+ const result = await this.runSingleCommand(attempt);
485
+ if (result.code === 0) {
254
486
  this.retryCount = 0;
487
+ return result;
488
+ }
489
+ if (this.options.retry > 0 && attempt < this.options.retry) {
490
+ attempt++;
491
+ this.log(this.t.yellow(`Retrying in 1s... (${attempt}/${this.options.retry})`));
492
+ await this.sleep(1e3);
493
+ continue;
494
+ }
495
+ this.retryCount = 0;
496
+ return result;
497
+ }
498
+ return { code: null, signal: null, duration: 0 };
499
+ }
500
+ async stopStartedProcess() {
501
+ if (!this.startedProcess) return;
502
+ const child = this.startedProcess;
503
+ const pid = child.pid;
504
+ this.stoppingStartedProcess = child;
505
+ await this.terminateProcess(child, this.options.start, this.options.start);
506
+ if (this.startedProcess === child) {
507
+ this.startedProcess = null;
508
+ }
509
+ if (this.stoppingStartedProcess === child) {
510
+ this.stoppingStartedProcess = null;
511
+ }
512
+ this.notifyHook("afterStop", { command: this.options.start, startCommand: this.options.start, pid });
513
+ }
514
+ startLongRunningProcess() {
515
+ if (this.disposed || !this.options.start) return;
516
+ this.log(`${this.timestamp()} ${this.t.cyan("Starting:")} ${this.t.bold(this.options.start)}`);
517
+ this.logJson("start", { command: this.options.start });
518
+ const child = this.spawnCommand(this.options.start, false);
519
+ this.startedProcess = child;
520
+ this.emit("start", this.options.start, child.pid);
521
+ setImmediate(() => {
522
+ if (this.startedProcess === child && !this.disposed && child.exitCode === null && child.signalCode === null) {
523
+ this.notifyHook("afterStart", { command: this.options.start, startCommand: this.options.start, pid: child.pid });
524
+ }
525
+ });
526
+ child.on("error", (err) => {
527
+ if (!this.disposed) {
528
+ this.log(this.t.red(`Start process error: ${err.message}`));
529
+ this.logJson("start_error", { command: this.options.start, error: err.message });
530
+ this.emit("fail", null);
531
+ }
532
+ if (this.startedProcess === child) {
533
+ this.startedProcess = null;
534
+ }
535
+ });
536
+ child.on("close", (code, signal) => {
537
+ const expectedStop = this.disposed || this.stoppingStartedProcess === child;
538
+ if (this.startedProcess === child) {
539
+ this.startedProcess = null;
540
+ }
541
+ if (this.stoppingStartedProcess === child) {
542
+ this.stoppingStartedProcess = null;
543
+ }
544
+ if (!expectedStop && !this.disposed) {
545
+ const exitMessage = signal ? ` (Signal: ${signal})` : ` (Exit code: ${code})`;
546
+ this.log(`${this.timestamp()} ${this.t.yellow("Start process exited")}${exitMessage}`);
547
+ this.logJson("start_exit", { exitCode: code, signal, expected: false });
548
+ }
549
+ if (!this.disposed) {
550
+ this.emit("startExit", code, signal ?? void 0, expectedStop);
255
551
  }
256
- this.log(this.t.dim("\u2500".repeat(40)));
257
552
  });
258
553
  }
259
- handleFileChange(filePath) {
554
+ async restartStartedProcess() {
555
+ if (!this.options.start) return;
556
+ this.emit("restart", this.options.start);
557
+ const canStart = await this.runHook("beforeStart", {
558
+ command: this.options.start,
559
+ startCommand: this.options.start
560
+ }, "gate");
561
+ if (!canStart) return;
562
+ if (this.startedProcess) {
563
+ const canStop = await this.runHook("beforeStop", {
564
+ command: this.options.start,
565
+ startCommand: this.options.start,
566
+ pid: this.startedProcess.pid
567
+ }, "gate");
568
+ if (!canStop) return;
569
+ await this.stopStartedProcess();
570
+ }
571
+ if (this.options.restartDelay > 0 && !this.disposed) {
572
+ await this.sleep(this.options.restartDelay);
573
+ }
574
+ this.startLongRunningProcess();
575
+ }
576
+ async runCycle(reason) {
260
577
  if (this.disposed) return;
261
- const currentHash = this.getHash(filePath);
262
- const lastHash = this.fileHashes.get(filePath);
263
- if (currentHash === lastHash) {
264
- this.logVerbose(this.t.gray(`Skipping ghost change: ${path.basename(filePath)}`));
265
- this.emit("skip", "content unchanged", filePath);
578
+ if (this.cycleRunning) {
579
+ this.pendingCycle = true;
580
+ this.logVerbose(this.t.yellow(`Lifecycle already running, coalescing ${reason} change...`));
581
+ this.skip("lifecycle already running");
266
582
  return;
267
583
  }
268
- if (currentHash) {
269
- this.fileHashes.set(filePath, currentHash);
584
+ this.cycleRunning = true;
585
+ try {
586
+ if (this.options.clearScreen) {
587
+ console.clear();
588
+ }
589
+ if (this.options.cmd) {
590
+ const canRunCommand = await this.runHook("beforeCommand", {
591
+ reason,
592
+ command: this.options.cmd,
593
+ startCommand: this.options.start || void 0
594
+ }, "gate");
595
+ if (!canRunCommand) return;
596
+ const result = await this.runCommandWithRetry();
597
+ const canContinue = await this.runHook("afterCommand", {
598
+ reason,
599
+ command: this.options.cmd,
600
+ startCommand: this.options.start || void 0,
601
+ exitCode: result.code,
602
+ signal: result.signal,
603
+ duration: result.duration
604
+ }, "gate");
605
+ if (!canContinue) return;
606
+ if (result.code === 0 && this.options.start && this.options.restartOnSuccess) {
607
+ await this.restartStartedProcess();
608
+ }
609
+ } else if (this.options.start && this.options.restartOnChange) {
610
+ await this.restartStartedProcess();
611
+ }
612
+ } finally {
613
+ this.cycleRunning = false;
614
+ if (!this.disposed) {
615
+ this.log(this.t.dim("-".repeat(40)));
616
+ }
617
+ if (this.pendingCycle && !this.disposed) {
618
+ this.pendingCycle = false;
619
+ setImmediate(() => {
620
+ void this.runCycle("coalesced");
621
+ });
622
+ }
270
623
  }
624
+ }
625
+ requestCycle(reason) {
626
+ void this.runCycle(reason);
627
+ }
628
+ skip(reason, filePath) {
629
+ this.emit("skip", reason, filePath);
630
+ this.notifyHook("onSkip", { skipReason: reason, file: filePath });
631
+ }
632
+ scheduleFileCycle(filePath, reason) {
271
633
  this.emit("change", filePath);
272
634
  if (this.timeout) clearTimeout(this.timeout);
273
635
  this.timeout = setTimeout(() => {
274
636
  if (!this.disposed) {
275
- this.log(`${this.timestamp()} ${this.t.yellow("\u26A1 Change detected:")} ${path.basename(filePath)}`);
276
- this.logJson("change", { file: path.basename(filePath) });
277
- this.runCommand();
637
+ this.log(`${this.timestamp()} ${this.t.yellow("Change detected:")} ${path.basename(filePath)}`);
638
+ this.logJson("change", { file: path.basename(filePath), reason });
639
+ this.requestCycle(reason);
278
640
  }
279
641
  }, this.options.delay);
280
642
  }
643
+ async handleFileChange(filePath) {
644
+ if (this.disposed) return;
645
+ const currentHash = this.getHash(filePath);
646
+ const lastHash = this.fileHashes.get(filePath);
647
+ if (currentHash === lastHash) {
648
+ this.logVerbose(this.t.gray(`Skipping ghost change: ${path.basename(filePath)}`));
649
+ this.skip("content unchanged", filePath);
650
+ return;
651
+ }
652
+ if (currentHash) {
653
+ this.fileHashes.set(filePath, currentHash);
654
+ }
655
+ let isGitChanged = true;
656
+ try {
657
+ isGitChanged = await this.isGitChangedFile(filePath);
658
+ } catch (error) {
659
+ const err = error instanceof Error ? error : new Error(String(error));
660
+ this.log(this.t.red(`Git changed-file check failed: ${err.message}`));
661
+ this.emit("error", err);
662
+ return;
663
+ }
664
+ if (!isGitChanged) {
665
+ this.logVerbose(this.t.gray(`Skipping unchanged git file: ${path.basename(filePath)}`));
666
+ this.skip("not changed from git base", filePath);
667
+ return;
668
+ }
669
+ this.scheduleFileCycle(filePath, "file");
670
+ }
281
671
  async start() {
282
672
  const validation = this.validate();
283
673
  if (!validation.valid) {
@@ -287,17 +677,23 @@ var _SteadyWatcher = class _SteadyWatcher extends EventEmitter {
287
677
  throw new Error(errorMsg);
288
678
  }
289
679
  const effectivePattern = this.getEffectivePattern();
290
- this.log(this.t.bold(`
291
- \u{1F52D} Steady Watch Initialized`));
680
+ this.log(this.t.bold("\nSteady Watch Initialized"));
292
681
  this.log(` ${this.t.dim("Pattern:")} ${effectivePattern}`);
293
- this.log(` ${this.t.dim("Command:")} ${this.options.cmd}`);
682
+ if (this.options.cmd) this.log(` ${this.t.dim("Command:")} ${this.options.cmd}`);
683
+ if (this.options.start) this.log(` ${this.t.dim("Start:")} ${this.options.start}`);
294
684
  this.log(` ${this.t.dim("Delay:")} ${this.options.delay}ms`);
295
685
  if (this.options.quiet) this.log(` ${this.t.dim("Mode:")} quiet`);
296
686
  if (this.options.killTimeout > 0) this.log(` ${this.t.dim("Kill:")} ${this.options.killTimeout}ms`);
687
+ if (this.options.restartDelay > 0) this.log(` ${this.t.dim("Restart delay:")} ${this.options.restartDelay}ms`);
297
688
  if (this.options.retry > 0) this.log(` ${this.t.dim("Retry:")} ${this.options.retry}x`);
689
+ if (this.options.gitChanged) this.log(` ${this.t.dim("Git:")} changed from ${this.options.gitBase}`);
690
+ if (this.options.initialRun) this.log(` ${this.t.dim("Initial:")} enabled`);
298
691
  if (this.options.mtimeOnly) this.log(` ${this.t.dim("Hash:")} mtime-only (fastest)`);
299
692
  else this.log(` ${this.t.dim("Hash:")} ${this.options.hash}`);
300
693
  this.log("");
694
+ if (this.options.gitChanged) {
695
+ await this.refreshGitChangedFiles();
696
+ }
301
697
  this.watcher = chokidar.watch(effectivePattern, {
302
698
  ignored: this.options.ignore,
303
699
  ignoreInitial: false,
@@ -307,55 +703,103 @@ var _SteadyWatcher = class _SteadyWatcher extends EventEmitter {
307
703
  }
308
704
  });
309
705
  this.watcher.on("ready", () => {
310
- this.log(this.t.green("\u{1F441}\uFE0F Watcher ready. Monitoring for changes..."));
706
+ this.watcherReady = true;
707
+ this.log(this.t.green("Watcher ready. Monitoring for changes..."));
311
708
  this.logVerbose(this.t.dim(` Tracking ${this.fileHashes.size} file(s)`));
312
709
  this.emit("ready");
710
+ if (this.options.initialRun) {
711
+ this.requestCycle("initial");
712
+ }
313
713
  });
314
714
  this.watcher.on("error", (error) => {
315
715
  this.log(this.t.red(`Watcher error: ${error.message}`));
316
716
  this.emit("error", error);
317
717
  });
318
718
  this.watcher.on("add", (filePath) => {
319
- if (this.disposed) return;
320
- const hash = this.getHash(filePath);
321
- if (hash) this.fileHashes.set(filePath, hash);
322
- this.logVerbose(this.t.dim(`Indexed: ${path.basename(filePath)}`));
719
+ void this.handleFileAdd(filePath);
720
+ });
721
+ this.watcher.on("change", (filePath) => {
722
+ void this.handleFileChange(filePath);
323
723
  });
324
- this.watcher.on("change", (filePath) => this.handleFileChange(filePath));
325
724
  this.watcher.on("unlink", (filePath) => {
326
- if (this.disposed) return;
327
- this.fileHashes.delete(filePath);
328
- this.logVerbose(this.t.dim(`Removed: ${path.basename(filePath)}`));
725
+ void this.handleFileUnlink(filePath);
329
726
  });
330
727
  }
728
+ async handleFileAdd(filePath) {
729
+ if (this.disposed) return;
730
+ const hash = this.getHash(filePath);
731
+ if (hash) this.fileHashes.set(filePath, hash);
732
+ this.logVerbose(this.t.dim(`Indexed: ${path.basename(filePath)}`));
733
+ if (!this.watcherReady || !this.options.gitChanged) return;
734
+ try {
735
+ if (await this.isGitChangedFile(filePath)) {
736
+ this.scheduleFileCycle(filePath, "file added");
737
+ }
738
+ } catch (error) {
739
+ const err = error instanceof Error ? error : new Error(String(error));
740
+ this.log(this.t.red(`Git changed-file check failed: ${err.message}`));
741
+ this.emit("error", err);
742
+ }
743
+ }
744
+ async handleFileUnlink(filePath) {
745
+ if (this.disposed) return;
746
+ this.fileHashes.delete(filePath);
747
+ this.logVerbose(this.t.dim(`Removed: ${path.basename(filePath)}`));
748
+ if (!this.watcherReady || !this.options.gitChanged) return;
749
+ try {
750
+ if (await this.isGitChangedFile(filePath)) {
751
+ this.scheduleFileCycle(filePath, "file removed");
752
+ }
753
+ } catch (error) {
754
+ const err = error instanceof Error ? error : new Error(String(error));
755
+ this.log(this.t.red(`Git changed-file check failed: ${err.message}`));
756
+ this.emit("error", err);
757
+ }
758
+ }
331
759
  async close() {
760
+ if (this.closePromise) {
761
+ return this.closePromise;
762
+ }
763
+ this.closePromise = this.closeInternal();
764
+ return this.closePromise;
765
+ }
766
+ async closeInternal() {
332
767
  if (this.disposed) return;
333
- this.disposed = true;
334
- this.log(this.t.yellow("\n\u{1F6D1} Shutting down..."));
768
+ this.log(this.t.yellow("\nShutting down..."));
335
769
  this.logJson("shutdown", {});
770
+ this.disposed = true;
336
771
  if (this.timeout) {
337
772
  clearTimeout(this.timeout);
338
773
  this.timeout = null;
339
774
  }
340
- if (this.killTimer) {
341
- clearTimeout(this.killTimer);
342
- this.killTimer = null;
343
- }
344
- if (this.activeProcess) {
345
- this.activeProcess.kill("SIGTERM");
346
- this.activeProcess = null;
775
+ if (this.lifecycleKillTimer) {
776
+ clearTimeout(this.lifecycleKillTimer);
777
+ this.lifecycleKillTimer = null;
347
778
  }
779
+ const lifecycleProcess = this.lifecycleProcess;
780
+ const startedProcess = this.startedProcess;
781
+ await Promise.all([
782
+ lifecycleProcess ? this.terminateProcess(lifecycleProcess, this.options.cmd, this.options.cmd) : Promise.resolve(),
783
+ startedProcess ? this.terminateProcess(startedProcess, this.options.start, this.options.start) : Promise.resolve()
784
+ ]);
785
+ this.lifecycleProcess = null;
786
+ this.startedProcess = null;
787
+ this.stoppingStartedProcess = null;
348
788
  if (this.watcher) {
349
789
  await this.watcher.close();
350
790
  this.watcher = null;
351
791
  }
792
+ this.watcherReady = false;
352
793
  this.removeAllListeners();
353
794
  }
354
795
  getTrackedFiles() {
355
796
  return Array.from(this.fileHashes.keys());
356
797
  }
357
798
  isCurrentlyRunning() {
358
- return this.isRunning;
799
+ return this.cycleRunning || Boolean(this.lifecycleProcess);
800
+ }
801
+ isStartedProcessRunning() {
802
+ return Boolean(this.startedProcess);
359
803
  }
360
804
  isDisposed() {
361
805
  return this.disposed;
@@ -372,7 +816,8 @@ function steadyWatch(options) {
372
816
  // src/cli.ts
373
817
  import fs2 from "fs";
374
818
  import path2 from "path";
375
- import { program } from "commander";
819
+ import { Command } from "commander";
820
+ var CLI_VERSION = "2.1.0";
376
821
  function loadConfig(configPath) {
377
822
  const searchPaths = [
378
823
  configPath,
@@ -414,27 +859,53 @@ function parseNumber(value, defaultValue) {
414
859
  }
415
860
  return defaultValue;
416
861
  }
862
+ function unsetDefaultedCliOptions(program, opts) {
863
+ const getSource = program.getOptionValueSource.bind(program);
864
+ const optionKeys = [
865
+ "delay",
866
+ "verbose",
867
+ "quiet",
868
+ "gitBase",
869
+ "retry",
870
+ "hash",
871
+ "theme"
872
+ ];
873
+ for (const key of optionKeys) {
874
+ if (getSource(key) === "default") {
875
+ delete opts[key];
876
+ }
877
+ }
878
+ }
417
879
  function mergeOptions(cliArgs, cliOpts, config) {
418
880
  return {
419
881
  pattern: cliArgs.pattern || config.pattern || "",
420
882
  cmd: cliOpts.cmd || config.cmd || "",
883
+ start: cliOpts.start || config.start || "",
884
+ restartOnSuccess: parseBoolean(cliOpts.restartOnSuccess ?? config.restartOnSuccess),
885
+ restartOnChange: parseBoolean(cliOpts.restartOnChange ?? config.restartOnChange),
886
+ initialRun: cliOpts.initialRun ?? config.initialRun,
421
887
  delay: parseNumber(cliOpts.delay ?? config.delay, 300),
422
888
  verbose: parseBoolean(cliOpts.verbose ?? config.verbose),
423
889
  quiet: parseBoolean(cliOpts.quiet ?? config.quiet),
424
890
  ignore: [...parseIgnorePatterns(cliOpts.ignore), ...config.ignore || []],
425
891
  ext: cliOpts.ext ? parseExtFilter(cliOpts.ext) : config.ext || [],
426
- killTimeout: parseNumber(cliOpts.killTimeout ?? config.killTimeout, 0),
892
+ gitChanged: parseBoolean(cliOpts.gitChanged ?? config.gitChanged),
893
+ gitBase: cliOpts.gitBase || config.gitBase || "HEAD",
894
+ killTimeout: cliOpts.killTimeout !== void 0 || config.killTimeout !== void 0 ? parseNumber(cliOpts.killTimeout ?? config.killTimeout, 0) : void 0,
895
+ restartDelay: parseNumber(cliOpts.restartDelay ?? config.restartDelay, 0),
427
896
  retry: parseNumber(cliOpts.retry ?? config.retry, 0),
428
- hash: cliOpts.hash || config.hash || "md5",
429
- mtimeOnly: cliOpts.noHash ?? config.mtimeOnly ?? false,
897
+ hash: (typeof cliOpts.hash === "string" ? cliOpts.hash : void 0) || config.hash || "md5",
898
+ mtimeOnly: cliOpts.hash === false || config.mtimeOnly || false,
430
899
  clearScreen: parseBoolean(cliOpts.clear ?? config.clearScreen),
431
900
  json: parseBoolean(cliOpts.json ?? config.json),
432
901
  theme: cliOpts.theme || config.theme || "default"
433
902
  };
434
903
  }
435
- function parseCliArgs() {
436
- program.name("steady-watch").description("Intelligent file watcher with debouncing and content hashing.").argument("[files]", 'Glob pattern to watch (e.g., "src/**/*.ts")').option("-c, --cmd <command>", "Command(s) to execute on change (supports quotes)").option("-d, --delay <ms>", "Debounce delay in milliseconds", "300").option("-v, --verbose", "Show hash calculations", false).option("-q, --quiet", "Minimize output", false).option("--ignore <patterns>", "Additional ignore patterns (comma-separated)").option("--ext <extensions>", "Filter by file extensions (e.g., .ts,.tsx)").option("--config <path>", "Path to config file").option("--kill-timeout <ms>", "Force kill process after timeout", "0").option("--retry <count>", "Retry failed command (0 = disabled)", "0").option("--hash <algorithm>", "Hash algorithm (md5, sha1, sha256)", "md5").option("--no-hash", "Use mtime only instead of content hash (fastest)").option("--clear", "Clear screen on each trigger").option("--json", "Output in JSON format").option("--theme <theme>", "Color theme (default, minimal, none)", "default").version("2.0.0").parse();
904
+ function parseCliArgs(argv = process.argv) {
905
+ const program = new Command();
906
+ program.name("steady-watch").description("Intelligent file watcher with debouncing and content hashing.").argument("[files]", 'Glob pattern to watch (e.g., "src/**/*.ts")').option("-c, --cmd <command>", "Command(s) to execute on change (supports quotes)").option("--start <command>", "Long-running command to start or restart after a successful lifecycle command").option("--restart-on-success", "Restart --start only when --cmd exits with code 0").option("--restart-on-change", "Restart --start directly on change when no --cmd is configured").option("--initial-run", "Run the initial lifecycle when the watcher starts").option("--no-initial-run", "Wait for the first file change before running the lifecycle").option("-d, --delay <ms>", "Debounce delay in milliseconds", "300").option("-v, --verbose", "Show hash calculations", false).option("-q, --quiet", "Minimize output", false).option("--ignore <patterns>", "Additional ignore patterns (comma-separated)").option("--ext <extensions>", "Filter by file extensions (e.g., .ts,.tsx)").option("--git-changed", "Only trigger for files changed from the git base ref").option("--git-base <ref>", "Git base ref for --git-changed", "HEAD").option("--config <path>", "Path to config file").option("--kill-timeout <ms>", "Force kill lifecycle commands and process shutdown after timeout").option("--restart-delay <ms>", "Delay between stopping and starting --start").option("--retry <count>", "Retry failed command (0 = disabled)", "0").option("--hash <algorithm>", "Hash algorithm (md5, sha1, sha256)", "md5").option("--no-hash", "Use mtime only instead of content hash (fastest)").option("--clear", "Clear screen on each trigger").option("--json", "Output in JSON format").option("--theme <theme>", "Color theme (default, minimal, none)", "default").version(CLI_VERSION).parse(argv);
437
907
  const cliOpts = program.opts();
908
+ unsetDefaultedCliOptions(program, cliOpts);
438
909
  const cliArgs = { pattern: program.args[0] || "" };
439
910
  return { args: cliArgs, opts: cliOpts };
440
911
  }
@@ -443,43 +914,47 @@ function parseCliArgs() {
443
914
  function runCli() {
444
915
  const { args, opts } = parseCliArgs();
445
916
  const config = loadConfig(opts.config);
446
- if (opts.config && config.cmd) {
447
- opts.cmd = config.cmd;
448
- }
449
917
  const options = mergeOptions(args, opts, config);
450
- if (!options.cmd) {
451
- console.error("Error: Command is required. Use -c option or config file.");
452
- process.exit(1);
453
- }
454
918
  const watcher = new SteadyWatcher(options);
919
+ let exiting = false;
920
+ const shutdown = async (exitCode, message) => {
921
+ if (exiting) return;
922
+ exiting = true;
923
+ if (message) console.error(message);
924
+ try {
925
+ await watcher.close();
926
+ } finally {
927
+ process.exit(exitCode);
928
+ }
929
+ };
455
930
  watcher.on("error", (err) => {
456
- console.error(`Error: ${err.message}`);
457
- process.exit(1);
931
+ void shutdown(1, `Error: ${err.message}`);
458
932
  });
459
933
  watcher.start().catch((err) => {
460
- console.error(`Failed to start: ${err.message}`);
461
- process.exit(1);
934
+ void shutdown(1, `Failed to start: ${err.message}`);
462
935
  });
463
- const shutdown = async (signal) => {
936
+ const shutdownSignal = async (signal) => {
937
+ if (exiting) return;
464
938
  console.log(`
465
939
  Received ${signal}, shutting down...`);
466
- await watcher.close();
467
- process.exit(0);
940
+ await shutdown(0);
468
941
  };
469
- process.on("SIGINT", () => shutdown("SIGINT"));
470
- process.on("SIGTERM", () => shutdown("SIGTERM"));
942
+ process.on("SIGINT", () => void shutdownSignal("SIGINT"));
943
+ process.on("SIGTERM", () => void shutdownSignal("SIGTERM"));
471
944
  process.on("uncaughtException", (err) => {
472
- console.error("Uncaught exception:", err);
473
- process.exit(1);
945
+ void shutdown(1, `Uncaught exception: ${err instanceof Error ? err.stack || err.message : String(err)}`);
474
946
  });
475
947
  }
476
- var isMain = __require.main === module || process.argv[1]?.includes("index.js") || process.argv[1]?.includes("steady-watch");
477
- if (isMain) {
948
+ if (__require.main === module) {
478
949
  runCli();
479
950
  }
480
951
  export {
952
+ CLI_VERSION,
481
953
  SteadyWatcher,
482
954
  getTheme,
955
+ loadConfig,
956
+ mergeOptions,
957
+ parseCliArgs,
483
958
  runCli,
484
959
  steadyWatch,
485
960
  themes