@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.js CHANGED
@@ -30,8 +30,12 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ CLI_VERSION: () => CLI_VERSION,
33
34
  SteadyWatcher: () => SteadyWatcher,
34
35
  getTheme: () => getTheme,
36
+ loadConfig: () => loadConfig,
37
+ mergeOptions: () => mergeOptions,
38
+ parseCliArgs: () => parseCliArgs,
35
39
  runCli: () => runCli,
36
40
  steadyWatch: () => steadyWatch,
37
41
  themes: () => themes
@@ -87,13 +91,19 @@ var _SteadyWatcher = class _SteadyWatcher extends import_events.EventEmitter {
87
91
  constructor(options) {
88
92
  super();
89
93
  this.watcher = null;
94
+ this.watcherReady = false;
90
95
  this.fileHashes = /* @__PURE__ */ new Map();
96
+ this.gitChangedFiles = /* @__PURE__ */ new Set();
91
97
  this.timeout = null;
92
- this.isRunning = false;
93
- this.activeProcess = null;
94
- this.killTimer = null;
98
+ this.cycleRunning = false;
99
+ this.pendingCycle = false;
100
+ this.lifecycleProcess = null;
101
+ this.lifecycleKillTimer = null;
102
+ this.startedProcess = null;
103
+ this.stoppingStartedProcess = null;
95
104
  this.retryCount = 0;
96
105
  this.disposed = false;
106
+ this.closePromise = null;
97
107
  this.options = this.normalizeOptions(options);
98
108
  this.t = getTheme(this.options.theme);
99
109
  }
@@ -104,15 +114,34 @@ var _SteadyWatcher = class _SteadyWatcher extends import_events.EventEmitter {
104
114
  ..._SteadyWatcher.DEFAULT_IGNORE,
105
115
  ...this.normalizeIgnorePatterns(options.ignore || [])
106
116
  ];
117
+ const start = options.start || "";
118
+ const hooks = {
119
+ ...options.hooks || {},
120
+ beforeCommand: options.beforeCommand || options.hooks?.beforeCommand,
121
+ afterCommand: options.afterCommand || options.hooks?.afterCommand,
122
+ beforeStop: options.beforeStop || options.hooks?.beforeStop,
123
+ afterStop: options.afterStop || options.hooks?.afterStop,
124
+ beforeStart: options.beforeStart || options.hooks?.beforeStart,
125
+ afterStart: options.afterStart || options.hooks?.afterStart,
126
+ onSkip: options.onSkip || options.hooks?.onSkip
127
+ };
107
128
  return {
108
129
  pattern: options.pattern,
109
- cmd: options.cmd,
130
+ cmd: options.cmd || "",
131
+ start,
132
+ restartOnSuccess: options.restartOnSuccess ?? false,
133
+ restartOnChange: options.restartOnChange ?? false,
134
+ initialRun: options.initialRun ?? Boolean(start),
135
+ hooks,
110
136
  delay: Math.max(0, options.delay ?? 300),
111
137
  verbose: options.verbose ?? false,
112
138
  quiet: options.quiet ?? false,
113
139
  ignore: mergedIgnore,
114
140
  ext: options.ext || [],
115
- killTimeout: Math.max(0, options.killTimeout ?? 0),
141
+ gitChanged: options.gitChanged ?? false,
142
+ gitBase: options.gitBase || "HEAD",
143
+ killTimeout: Math.max(0, options.killTimeout ?? (start ? 5e3 : 0)),
144
+ restartDelay: Math.max(0, options.restartDelay ?? 0),
116
145
  retry: Math.max(0, options.retry ?? 0),
117
146
  hash,
118
147
  mtimeOnly: options.mtimeOnly ?? false,
@@ -136,8 +165,23 @@ var _SteadyWatcher = class _SteadyWatcher extends import_events.EventEmitter {
136
165
  if (!this.options.pattern) {
137
166
  errors.push("Pattern is required");
138
167
  }
139
- if (!this.options.cmd) {
140
- errors.push("Command is required");
168
+ if (!this.options.cmd && !this.options.start) {
169
+ errors.push("Command is required. Use --cmd, or use --start with --restart-on-change");
170
+ }
171
+ if (this.options.start && !this.options.restartOnSuccess && !this.options.restartOnChange) {
172
+ errors.push("A start command requires --restart-on-success or --restart-on-change");
173
+ }
174
+ if (this.options.restartOnSuccess && (!this.options.cmd || !this.options.start)) {
175
+ errors.push("--restart-on-success requires both --cmd and --start");
176
+ }
177
+ if (this.options.restartOnChange && !this.options.start) {
178
+ errors.push("--restart-on-change requires --start");
179
+ }
180
+ if (this.options.restartOnChange && this.options.cmd) {
181
+ errors.push("--restart-on-change cannot be combined with --cmd; use --restart-on-success");
182
+ }
183
+ if (!this.options.cmd && this.options.start && !this.options.restartOnChange) {
184
+ errors.push("--restart-on-change is required when --start is used without --cmd");
141
185
  }
142
186
  if (this.options.delay < 0) {
143
187
  errors.push("Delay must be a non-negative number");
@@ -145,6 +189,12 @@ var _SteadyWatcher = class _SteadyWatcher extends import_events.EventEmitter {
145
189
  if (this.options.killTimeout < 0) {
146
190
  errors.push("Kill timeout must be a non-negative number");
147
191
  }
192
+ if (this.options.restartDelay < 0) {
193
+ errors.push("Restart delay must be a non-negative number");
194
+ }
195
+ if (this.options.gitChanged && !this.options.gitBase) {
196
+ errors.push("Git base must be set when git-changed mode is enabled");
197
+ }
148
198
  if (this.options.retry < 0) {
149
199
  errors.push("Retry must be a non-negative number");
150
200
  }
@@ -171,6 +221,36 @@ var _SteadyWatcher = class _SteadyWatcher extends import_events.EventEmitter {
171
221
  return null;
172
222
  }
173
223
  }
224
+ normalizeGitPath(filePath) {
225
+ return import_path.default.relative(process.cwd(), import_path.default.resolve(filePath)).replace(/\\/g, "/");
226
+ }
227
+ runGit(args) {
228
+ return new Promise((resolve, reject) => {
229
+ (0, import_child_process.execFile)("git", args, { cwd: process.cwd(), maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
230
+ if (error) {
231
+ const message = stderr.trim() || error.message;
232
+ reject(new Error(`git ${args.join(" ")} failed: ${message}`));
233
+ return;
234
+ }
235
+ resolve(stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean));
236
+ });
237
+ });
238
+ }
239
+ async refreshGitChangedFiles() {
240
+ if (!this.options.gitChanged) return;
241
+ const [unstaged, staged, untracked] = await Promise.all([
242
+ this.runGit(["diff", "--name-only", "--relative", this.options.gitBase, "--"]),
243
+ this.runGit(["diff", "--name-only", "--cached", "--relative", this.options.gitBase, "--"]),
244
+ this.runGit(["ls-files", "--others", "--exclude-standard"])
245
+ ]);
246
+ this.gitChangedFiles = new Set([...unstaged, ...staged, ...untracked].map((file) => file.replace(/\\/g, "/")));
247
+ }
248
+ async isGitChangedFile(filePath) {
249
+ if (!this.options.gitChanged) return true;
250
+ await this.refreshGitChangedFiles();
251
+ const relativePath = this.normalizeGitPath(filePath);
252
+ return this.gitChangedFiles.has(relativePath);
253
+ }
174
254
  log(...args) {
175
255
  if (!this.options.quiet && !this.disposed) console.log(...args);
176
256
  }
@@ -187,6 +267,33 @@ var _SteadyWatcher = class _SteadyWatcher extends import_events.EventEmitter {
187
267
  timestamp() {
188
268
  return this.t.gray(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}]`);
189
269
  }
270
+ async runHook(hook, context, mode) {
271
+ const callback = this.options.hooks[hook];
272
+ if (!callback) return true;
273
+ const hookContext = {
274
+ phase: hook,
275
+ timestamp: /* @__PURE__ */ new Date(),
276
+ ...context
277
+ };
278
+ try {
279
+ await callback(Object.freeze(hookContext));
280
+ return true;
281
+ } catch (error) {
282
+ const err = error instanceof Error ? error : new Error(String(error));
283
+ this.log(this.t.red(`Hook ${hook} failed: ${err.message}`));
284
+ this.logJson("hook_error", { hook, error: err.message });
285
+ this.emit("hookError", hook, err);
286
+ if (mode === "notify") return true;
287
+ this.emit("fail", null);
288
+ return false;
289
+ }
290
+ }
291
+ notifyHook(hook, context) {
292
+ void this.runHook(hook, context, "notify");
293
+ }
294
+ sleep(ms) {
295
+ return new Promise((resolve) => setTimeout(resolve, ms));
296
+ }
190
297
  parseCommand(cmdString) {
191
298
  const tokens = [];
192
299
  let current = "";
@@ -195,7 +302,6 @@ var _SteadyWatcher = class _SteadyWatcher extends import_events.EventEmitter {
195
302
  let escaped = false;
196
303
  for (let i = 0; i < cmdString.length; i++) {
197
304
  const char = cmdString[i];
198
- const prevChar = i > 0 ? cmdString[i - 1] : "";
199
305
  if (escaped) {
200
306
  current += char;
201
307
  escaped = false;
@@ -225,92 +331,380 @@ var _SteadyWatcher = class _SteadyWatcher extends import_events.EventEmitter {
225
331
  }
226
332
  return { cmd: tokens[0] || "", args: tokens.slice(1) };
227
333
  }
228
- runCommand() {
229
- if (this.disposed) return;
230
- if (this.isRunning) {
231
- this.logVerbose(this.t.yellow("\u26A0\uFE0F Previous command still running, skipping..."));
232
- this.emit("skip", "previous command still running");
233
- return;
334
+ commandNeedsShell(command) {
335
+ return /(?:&&|\|\||[|<>])/.test(command);
336
+ }
337
+ resolveWindowsCommand(command) {
338
+ if (process.platform !== "win32") return null;
339
+ if (import_path.default.isAbsolute(command) || command.includes("\\") || command.includes("/")) {
340
+ return import_fs.default.existsSync(command) ? command : null;
234
341
  }
235
- if (this.options.clearScreen) {
236
- console.clear();
342
+ const pathEntries = (process.env.PATH || "").split(import_path.default.delimiter).filter(Boolean);
343
+ const pathExts = (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
344
+ const hasExt = Boolean(import_path.default.extname(command));
345
+ const candidates = hasExt ? [command] : pathExts.map((ext) => `${command}${ext.toLowerCase()}`);
346
+ for (const entry of pathEntries) {
347
+ for (const candidate of candidates) {
348
+ const fullPath = import_path.default.join(entry, candidate);
349
+ if (import_fs.default.existsSync(fullPath)) return fullPath;
350
+ }
237
351
  }
238
- this.isRunning = true;
239
- const retryInfo = this.retryCount > 0 ? ` (Retry ${this.retryCount}/${this.options.retry})` : "";
240
- this.log(`${this.timestamp()} ${this.t.cyan("\u{1F680} Triggering:")} ${this.t.bold(this.options.cmd)}${retryInfo}`);
241
- this.logJson("trigger", { command: this.options.cmd, retry: this.retryCount });
242
- this.emit("trigger", this.options.cmd);
243
- const { cmd, args } = this.parseCommand(this.options.cmd);
244
- const startTime = Date.now();
245
- this.activeProcess = (0, import_child_process.spawn)(cmd, args, {
352
+ return null;
353
+ }
354
+ shouldUseShell(command, preferShell) {
355
+ if (preferShell || this.commandNeedsShell(command)) return true;
356
+ const parsed = this.parseCommand(command);
357
+ if (!parsed.cmd) return true;
358
+ if (process.platform !== "win32") return false;
359
+ const resolved = this.resolveWindowsCommand(parsed.cmd);
360
+ if (!resolved) return true;
361
+ return /\.(cmd|bat)$/i.test(resolved);
362
+ }
363
+ spawnCommand(command, preferShell) {
364
+ const useShell = this.shouldUseShell(command, preferShell);
365
+ const parsed = this.parseCommand(command);
366
+ const child = useShell ? (0, import_child_process.spawn)(command, {
246
367
  stdio: "inherit",
247
368
  shell: true,
248
- env: { ...process.env }
369
+ env: { ...process.env },
370
+ detached: process.platform !== "win32",
371
+ windowsHide: false
372
+ }) : (0, import_child_process.spawn)(parsed.cmd, parsed.args, {
373
+ stdio: "inherit",
374
+ env: { ...process.env },
375
+ detached: process.platform !== "win32",
376
+ windowsHide: false
249
377
  });
250
- if (this.options.killTimeout > 0) {
251
- this.killTimer = setTimeout(() => {
252
- if (this.activeProcess && !this.disposed) {
253
- this.log(this.t.yellow(`\u26A0\uFE0F Process timeout (${this.options.killTimeout}ms), force killing...`));
254
- this.activeProcess.kill("SIGKILL");
255
- }
256
- }, this.options.killTimeout);
257
- }
258
- this.activeProcess.on("error", (err) => {
259
- this.log(this.t.red(`Process error: ${err.message}`));
260
- this.emit("error", err);
378
+ return Object.assign(child, { steadyWatchUsesShell: useShell });
379
+ }
380
+ runTaskkill(pid, force) {
381
+ return new Promise((resolve) => {
382
+ const args = ["/pid", String(pid), "/T"];
383
+ if (force) args.push("/F");
384
+ const killer = (0, import_child_process.spawn)("taskkill", args, {
385
+ stdio: "ignore",
386
+ windowsHide: true
387
+ });
388
+ let settled = false;
389
+ const finish = (ok) => {
390
+ if (settled) return;
391
+ settled = true;
392
+ resolve(ok);
393
+ };
394
+ killer.on("close", (code) => finish(code === 0));
395
+ killer.on("error", () => finish(false));
261
396
  });
262
- this.activeProcess.on("close", (code, signal) => {
263
- if (this.disposed) return;
264
- this.isRunning = false;
265
- if (this.killTimer) {
266
- clearTimeout(this.killTimer);
267
- this.killTimer = null;
268
- }
269
- this.activeProcess = null;
270
- const duration = ((Date.now() - startTime) / 1e3).toFixed(2);
271
- if (code === 0) {
272
- this.log(`${this.timestamp()} ${this.t.green("\u2714 Done")} in ${duration}s`);
273
- this.logJson("done", { duration: parseFloat(duration) });
274
- this.emit("done", parseFloat(duration));
275
- this.retryCount = 0;
397
+ }
398
+ async forceKillProcess(child, label) {
399
+ if (process.platform === "win32" && child.pid) {
400
+ const killedTree = await this.runTaskkill(child.pid, true);
401
+ if (killedTree) return;
402
+ }
403
+ try {
404
+ if (process.platform !== "win32" && child.pid) {
405
+ process.kill(-child.pid, "SIGKILL");
276
406
  } else {
277
- const exitMessage = signal ? ` (Signal: ${signal})` : ` (Exit code: ${code})`;
278
- this.log(`${this.timestamp()} ${this.t.red("\u2718 Failed")}${exitMessage}`);
279
- this.logJson("failed", { exitCode: code, signal });
280
- this.emit("fail", code, signal ?? void 0);
281
- if (this.options.retry > 0 && this.retryCount < this.options.retry) {
282
- this.retryCount++;
283
- this.log(this.t.yellow(`\u{1F504} Retrying in 1s... (${this.retryCount}/${this.options.retry})`));
284
- setTimeout(() => this.runCommand(), 1e3);
407
+ child.kill("SIGKILL");
408
+ }
409
+ } catch {
410
+ this.logVerbose(this.t.gray(`Process already exited before force kill: ${label}`));
411
+ }
412
+ }
413
+ terminateProcess(child, command, label) {
414
+ return new Promise((resolve) => {
415
+ let settled = false;
416
+ const timeoutMs = this.options.killTimeout > 0 ? this.options.killTimeout : 5e3;
417
+ let forceTimer = null;
418
+ const finish = () => {
419
+ if (settled) return;
420
+ settled = true;
421
+ if (forceTimer) clearTimeout(forceTimer);
422
+ resolve();
423
+ };
424
+ if (child.exitCode !== null || child.signalCode !== null) {
425
+ finish();
426
+ return;
427
+ }
428
+ child.once("close", finish);
429
+ this.emit("stop", command, "SIGTERM");
430
+ const requestGracefulStop = async () => {
431
+ try {
432
+ if (process.platform === "win32" && child.pid) {
433
+ const killedTree = await this.runTaskkill(child.pid, false);
434
+ if (killedTree) {
435
+ finish();
436
+ return;
437
+ }
438
+ }
439
+ if (process.platform !== "win32" && child.pid) {
440
+ process.kill(-child.pid, "SIGTERM");
441
+ } else {
442
+ child.kill("SIGTERM");
443
+ }
444
+ } catch {
445
+ finish();
285
446
  return;
286
447
  }
448
+ };
449
+ void requestGracefulStop();
450
+ forceTimer = setTimeout(() => {
451
+ if (!settled) {
452
+ this.log(this.t.yellow(`Process did not exit after ${timeoutMs}ms, force killing ${label}...`));
453
+ void (async () => {
454
+ await this.forceKillProcess(child, label);
455
+ finish();
456
+ })();
457
+ }
458
+ }, timeoutMs);
459
+ });
460
+ }
461
+ runSingleCommand(attempt) {
462
+ if (this.disposed) {
463
+ return Promise.resolve({ code: null, signal: null, duration: 0 });
464
+ }
465
+ this.retryCount = attempt;
466
+ const retryInfo = attempt > 0 ? ` (Retry ${attempt}/${this.options.retry})` : "";
467
+ this.log(`${this.timestamp()} ${this.t.cyan("Triggering:")} ${this.t.bold(this.options.cmd)}${retryInfo}`);
468
+ this.logJson("trigger", { command: this.options.cmd, retry: attempt });
469
+ this.emit("trigger", this.options.cmd);
470
+ const startTime = Date.now();
471
+ const child = this.spawnCommand(this.options.cmd, false);
472
+ this.lifecycleProcess = child;
473
+ const commandTimeout = this.options.killTimeout;
474
+ if (commandTimeout > 0) {
475
+ this.lifecycleKillTimer = setTimeout(() => {
476
+ if (this.lifecycleProcess === child && !this.disposed) {
477
+ this.log(this.t.yellow(`Process timeout (${commandTimeout}ms), force killing...`));
478
+ void this.forceKillProcess(child, this.options.cmd);
479
+ }
480
+ }, commandTimeout);
481
+ }
482
+ return new Promise((resolve) => {
483
+ let settled = false;
484
+ const finish = (code, signal) => {
485
+ if (settled) return;
486
+ settled = true;
487
+ if (this.lifecycleKillTimer) {
488
+ clearTimeout(this.lifecycleKillTimer);
489
+ this.lifecycleKillTimer = null;
490
+ }
491
+ if (this.lifecycleProcess === child) {
492
+ this.lifecycleProcess = null;
493
+ }
494
+ const duration = (Date.now() - startTime) / 1e3;
495
+ if (!this.disposed) {
496
+ if (code === 0) {
497
+ this.log(`${this.timestamp()} ${this.t.green("Done")} in ${duration.toFixed(2)}s`);
498
+ this.logJson("done", { duration: parseFloat(duration.toFixed(2)) });
499
+ this.emit("done", parseFloat(duration.toFixed(2)));
500
+ } else {
501
+ const exitMessage = signal ? ` (Signal: ${signal})` : ` (Exit code: ${code})`;
502
+ this.log(`${this.timestamp()} ${this.t.red("Failed")}${exitMessage}`);
503
+ this.logJson("failed", { exitCode: code, signal });
504
+ this.emit("fail", code, signal ?? void 0);
505
+ }
506
+ }
507
+ resolve({ code, signal, duration });
508
+ };
509
+ child.on("error", (err) => {
510
+ if (!this.disposed) {
511
+ this.log(this.t.red(`Process error: ${err.message}`));
512
+ }
513
+ finish(null, null);
514
+ });
515
+ child.on("close", finish);
516
+ });
517
+ }
518
+ async runCommandWithRetry() {
519
+ let attempt = 0;
520
+ while (!this.disposed) {
521
+ const result = await this.runSingleCommand(attempt);
522
+ if (result.code === 0) {
287
523
  this.retryCount = 0;
524
+ return result;
525
+ }
526
+ if (this.options.retry > 0 && attempt < this.options.retry) {
527
+ attempt++;
528
+ this.log(this.t.yellow(`Retrying in 1s... (${attempt}/${this.options.retry})`));
529
+ await this.sleep(1e3);
530
+ continue;
531
+ }
532
+ this.retryCount = 0;
533
+ return result;
534
+ }
535
+ return { code: null, signal: null, duration: 0 };
536
+ }
537
+ async stopStartedProcess() {
538
+ if (!this.startedProcess) return;
539
+ const child = this.startedProcess;
540
+ const pid = child.pid;
541
+ this.stoppingStartedProcess = child;
542
+ await this.terminateProcess(child, this.options.start, this.options.start);
543
+ if (this.startedProcess === child) {
544
+ this.startedProcess = null;
545
+ }
546
+ if (this.stoppingStartedProcess === child) {
547
+ this.stoppingStartedProcess = null;
548
+ }
549
+ this.notifyHook("afterStop", { command: this.options.start, startCommand: this.options.start, pid });
550
+ }
551
+ startLongRunningProcess() {
552
+ if (this.disposed || !this.options.start) return;
553
+ this.log(`${this.timestamp()} ${this.t.cyan("Starting:")} ${this.t.bold(this.options.start)}`);
554
+ this.logJson("start", { command: this.options.start });
555
+ const child = this.spawnCommand(this.options.start, false);
556
+ this.startedProcess = child;
557
+ this.emit("start", this.options.start, child.pid);
558
+ setImmediate(() => {
559
+ if (this.startedProcess === child && !this.disposed && child.exitCode === null && child.signalCode === null) {
560
+ this.notifyHook("afterStart", { command: this.options.start, startCommand: this.options.start, pid: child.pid });
561
+ }
562
+ });
563
+ child.on("error", (err) => {
564
+ if (!this.disposed) {
565
+ this.log(this.t.red(`Start process error: ${err.message}`));
566
+ this.logJson("start_error", { command: this.options.start, error: err.message });
567
+ this.emit("fail", null);
568
+ }
569
+ if (this.startedProcess === child) {
570
+ this.startedProcess = null;
571
+ }
572
+ });
573
+ child.on("close", (code, signal) => {
574
+ const expectedStop = this.disposed || this.stoppingStartedProcess === child;
575
+ if (this.startedProcess === child) {
576
+ this.startedProcess = null;
577
+ }
578
+ if (this.stoppingStartedProcess === child) {
579
+ this.stoppingStartedProcess = null;
580
+ }
581
+ if (!expectedStop && !this.disposed) {
582
+ const exitMessage = signal ? ` (Signal: ${signal})` : ` (Exit code: ${code})`;
583
+ this.log(`${this.timestamp()} ${this.t.yellow("Start process exited")}${exitMessage}`);
584
+ this.logJson("start_exit", { exitCode: code, signal, expected: false });
585
+ }
586
+ if (!this.disposed) {
587
+ this.emit("startExit", code, signal ?? void 0, expectedStop);
288
588
  }
289
- this.log(this.t.dim("\u2500".repeat(40)));
290
589
  });
291
590
  }
292
- handleFileChange(filePath) {
591
+ async restartStartedProcess() {
592
+ if (!this.options.start) return;
593
+ this.emit("restart", this.options.start);
594
+ const canStart = await this.runHook("beforeStart", {
595
+ command: this.options.start,
596
+ startCommand: this.options.start
597
+ }, "gate");
598
+ if (!canStart) return;
599
+ if (this.startedProcess) {
600
+ const canStop = await this.runHook("beforeStop", {
601
+ command: this.options.start,
602
+ startCommand: this.options.start,
603
+ pid: this.startedProcess.pid
604
+ }, "gate");
605
+ if (!canStop) return;
606
+ await this.stopStartedProcess();
607
+ }
608
+ if (this.options.restartDelay > 0 && !this.disposed) {
609
+ await this.sleep(this.options.restartDelay);
610
+ }
611
+ this.startLongRunningProcess();
612
+ }
613
+ async runCycle(reason) {
293
614
  if (this.disposed) return;
294
- const currentHash = this.getHash(filePath);
295
- const lastHash = this.fileHashes.get(filePath);
296
- if (currentHash === lastHash) {
297
- this.logVerbose(this.t.gray(`Skipping ghost change: ${import_path.default.basename(filePath)}`));
298
- this.emit("skip", "content unchanged", filePath);
615
+ if (this.cycleRunning) {
616
+ this.pendingCycle = true;
617
+ this.logVerbose(this.t.yellow(`Lifecycle already running, coalescing ${reason} change...`));
618
+ this.skip("lifecycle already running");
299
619
  return;
300
620
  }
301
- if (currentHash) {
302
- this.fileHashes.set(filePath, currentHash);
621
+ this.cycleRunning = true;
622
+ try {
623
+ if (this.options.clearScreen) {
624
+ console.clear();
625
+ }
626
+ if (this.options.cmd) {
627
+ const canRunCommand = await this.runHook("beforeCommand", {
628
+ reason,
629
+ command: this.options.cmd,
630
+ startCommand: this.options.start || void 0
631
+ }, "gate");
632
+ if (!canRunCommand) return;
633
+ const result = await this.runCommandWithRetry();
634
+ const canContinue = await this.runHook("afterCommand", {
635
+ reason,
636
+ command: this.options.cmd,
637
+ startCommand: this.options.start || void 0,
638
+ exitCode: result.code,
639
+ signal: result.signal,
640
+ duration: result.duration
641
+ }, "gate");
642
+ if (!canContinue) return;
643
+ if (result.code === 0 && this.options.start && this.options.restartOnSuccess) {
644
+ await this.restartStartedProcess();
645
+ }
646
+ } else if (this.options.start && this.options.restartOnChange) {
647
+ await this.restartStartedProcess();
648
+ }
649
+ } finally {
650
+ this.cycleRunning = false;
651
+ if (!this.disposed) {
652
+ this.log(this.t.dim("-".repeat(40)));
653
+ }
654
+ if (this.pendingCycle && !this.disposed) {
655
+ this.pendingCycle = false;
656
+ setImmediate(() => {
657
+ void this.runCycle("coalesced");
658
+ });
659
+ }
303
660
  }
661
+ }
662
+ requestCycle(reason) {
663
+ void this.runCycle(reason);
664
+ }
665
+ skip(reason, filePath) {
666
+ this.emit("skip", reason, filePath);
667
+ this.notifyHook("onSkip", { skipReason: reason, file: filePath });
668
+ }
669
+ scheduleFileCycle(filePath, reason) {
304
670
  this.emit("change", filePath);
305
671
  if (this.timeout) clearTimeout(this.timeout);
306
672
  this.timeout = setTimeout(() => {
307
673
  if (!this.disposed) {
308
- this.log(`${this.timestamp()} ${this.t.yellow("\u26A1 Change detected:")} ${import_path.default.basename(filePath)}`);
309
- this.logJson("change", { file: import_path.default.basename(filePath) });
310
- this.runCommand();
674
+ this.log(`${this.timestamp()} ${this.t.yellow("Change detected:")} ${import_path.default.basename(filePath)}`);
675
+ this.logJson("change", { file: import_path.default.basename(filePath), reason });
676
+ this.requestCycle(reason);
311
677
  }
312
678
  }, this.options.delay);
313
679
  }
680
+ async handleFileChange(filePath) {
681
+ if (this.disposed) return;
682
+ const currentHash = this.getHash(filePath);
683
+ const lastHash = this.fileHashes.get(filePath);
684
+ if (currentHash === lastHash) {
685
+ this.logVerbose(this.t.gray(`Skipping ghost change: ${import_path.default.basename(filePath)}`));
686
+ this.skip("content unchanged", filePath);
687
+ return;
688
+ }
689
+ if (currentHash) {
690
+ this.fileHashes.set(filePath, currentHash);
691
+ }
692
+ let isGitChanged = true;
693
+ try {
694
+ isGitChanged = await this.isGitChangedFile(filePath);
695
+ } catch (error) {
696
+ const err = error instanceof Error ? error : new Error(String(error));
697
+ this.log(this.t.red(`Git changed-file check failed: ${err.message}`));
698
+ this.emit("error", err);
699
+ return;
700
+ }
701
+ if (!isGitChanged) {
702
+ this.logVerbose(this.t.gray(`Skipping unchanged git file: ${import_path.default.basename(filePath)}`));
703
+ this.skip("not changed from git base", filePath);
704
+ return;
705
+ }
706
+ this.scheduleFileCycle(filePath, "file");
707
+ }
314
708
  async start() {
315
709
  const validation = this.validate();
316
710
  if (!validation.valid) {
@@ -320,17 +714,23 @@ var _SteadyWatcher = class _SteadyWatcher extends import_events.EventEmitter {
320
714
  throw new Error(errorMsg);
321
715
  }
322
716
  const effectivePattern = this.getEffectivePattern();
323
- this.log(this.t.bold(`
324
- \u{1F52D} Steady Watch Initialized`));
717
+ this.log(this.t.bold("\nSteady Watch Initialized"));
325
718
  this.log(` ${this.t.dim("Pattern:")} ${effectivePattern}`);
326
- this.log(` ${this.t.dim("Command:")} ${this.options.cmd}`);
719
+ if (this.options.cmd) this.log(` ${this.t.dim("Command:")} ${this.options.cmd}`);
720
+ if (this.options.start) this.log(` ${this.t.dim("Start:")} ${this.options.start}`);
327
721
  this.log(` ${this.t.dim("Delay:")} ${this.options.delay}ms`);
328
722
  if (this.options.quiet) this.log(` ${this.t.dim("Mode:")} quiet`);
329
723
  if (this.options.killTimeout > 0) this.log(` ${this.t.dim("Kill:")} ${this.options.killTimeout}ms`);
724
+ if (this.options.restartDelay > 0) this.log(` ${this.t.dim("Restart delay:")} ${this.options.restartDelay}ms`);
330
725
  if (this.options.retry > 0) this.log(` ${this.t.dim("Retry:")} ${this.options.retry}x`);
726
+ if (this.options.gitChanged) this.log(` ${this.t.dim("Git:")} changed from ${this.options.gitBase}`);
727
+ if (this.options.initialRun) this.log(` ${this.t.dim("Initial:")} enabled`);
331
728
  if (this.options.mtimeOnly) this.log(` ${this.t.dim("Hash:")} mtime-only (fastest)`);
332
729
  else this.log(` ${this.t.dim("Hash:")} ${this.options.hash}`);
333
730
  this.log("");
731
+ if (this.options.gitChanged) {
732
+ await this.refreshGitChangedFiles();
733
+ }
334
734
  this.watcher = import_chokidar.default.watch(effectivePattern, {
335
735
  ignored: this.options.ignore,
336
736
  ignoreInitial: false,
@@ -340,55 +740,103 @@ var _SteadyWatcher = class _SteadyWatcher extends import_events.EventEmitter {
340
740
  }
341
741
  });
342
742
  this.watcher.on("ready", () => {
343
- this.log(this.t.green("\u{1F441}\uFE0F Watcher ready. Monitoring for changes..."));
743
+ this.watcherReady = true;
744
+ this.log(this.t.green("Watcher ready. Monitoring for changes..."));
344
745
  this.logVerbose(this.t.dim(` Tracking ${this.fileHashes.size} file(s)`));
345
746
  this.emit("ready");
747
+ if (this.options.initialRun) {
748
+ this.requestCycle("initial");
749
+ }
346
750
  });
347
751
  this.watcher.on("error", (error) => {
348
752
  this.log(this.t.red(`Watcher error: ${error.message}`));
349
753
  this.emit("error", error);
350
754
  });
351
755
  this.watcher.on("add", (filePath) => {
352
- if (this.disposed) return;
353
- const hash = this.getHash(filePath);
354
- if (hash) this.fileHashes.set(filePath, hash);
355
- this.logVerbose(this.t.dim(`Indexed: ${import_path.default.basename(filePath)}`));
756
+ void this.handleFileAdd(filePath);
757
+ });
758
+ this.watcher.on("change", (filePath) => {
759
+ void this.handleFileChange(filePath);
356
760
  });
357
- this.watcher.on("change", (filePath) => this.handleFileChange(filePath));
358
761
  this.watcher.on("unlink", (filePath) => {
359
- if (this.disposed) return;
360
- this.fileHashes.delete(filePath);
361
- this.logVerbose(this.t.dim(`Removed: ${import_path.default.basename(filePath)}`));
762
+ void this.handleFileUnlink(filePath);
362
763
  });
363
764
  }
765
+ async handleFileAdd(filePath) {
766
+ if (this.disposed) return;
767
+ const hash = this.getHash(filePath);
768
+ if (hash) this.fileHashes.set(filePath, hash);
769
+ this.logVerbose(this.t.dim(`Indexed: ${import_path.default.basename(filePath)}`));
770
+ if (!this.watcherReady || !this.options.gitChanged) return;
771
+ try {
772
+ if (await this.isGitChangedFile(filePath)) {
773
+ this.scheduleFileCycle(filePath, "file added");
774
+ }
775
+ } catch (error) {
776
+ const err = error instanceof Error ? error : new Error(String(error));
777
+ this.log(this.t.red(`Git changed-file check failed: ${err.message}`));
778
+ this.emit("error", err);
779
+ }
780
+ }
781
+ async handleFileUnlink(filePath) {
782
+ if (this.disposed) return;
783
+ this.fileHashes.delete(filePath);
784
+ this.logVerbose(this.t.dim(`Removed: ${import_path.default.basename(filePath)}`));
785
+ if (!this.watcherReady || !this.options.gitChanged) return;
786
+ try {
787
+ if (await this.isGitChangedFile(filePath)) {
788
+ this.scheduleFileCycle(filePath, "file removed");
789
+ }
790
+ } catch (error) {
791
+ const err = error instanceof Error ? error : new Error(String(error));
792
+ this.log(this.t.red(`Git changed-file check failed: ${err.message}`));
793
+ this.emit("error", err);
794
+ }
795
+ }
364
796
  async close() {
797
+ if (this.closePromise) {
798
+ return this.closePromise;
799
+ }
800
+ this.closePromise = this.closeInternal();
801
+ return this.closePromise;
802
+ }
803
+ async closeInternal() {
365
804
  if (this.disposed) return;
366
- this.disposed = true;
367
- this.log(this.t.yellow("\n\u{1F6D1} Shutting down..."));
805
+ this.log(this.t.yellow("\nShutting down..."));
368
806
  this.logJson("shutdown", {});
807
+ this.disposed = true;
369
808
  if (this.timeout) {
370
809
  clearTimeout(this.timeout);
371
810
  this.timeout = null;
372
811
  }
373
- if (this.killTimer) {
374
- clearTimeout(this.killTimer);
375
- this.killTimer = null;
376
- }
377
- if (this.activeProcess) {
378
- this.activeProcess.kill("SIGTERM");
379
- this.activeProcess = null;
812
+ if (this.lifecycleKillTimer) {
813
+ clearTimeout(this.lifecycleKillTimer);
814
+ this.lifecycleKillTimer = null;
380
815
  }
816
+ const lifecycleProcess = this.lifecycleProcess;
817
+ const startedProcess = this.startedProcess;
818
+ await Promise.all([
819
+ lifecycleProcess ? this.terminateProcess(lifecycleProcess, this.options.cmd, this.options.cmd) : Promise.resolve(),
820
+ startedProcess ? this.terminateProcess(startedProcess, this.options.start, this.options.start) : Promise.resolve()
821
+ ]);
822
+ this.lifecycleProcess = null;
823
+ this.startedProcess = null;
824
+ this.stoppingStartedProcess = null;
381
825
  if (this.watcher) {
382
826
  await this.watcher.close();
383
827
  this.watcher = null;
384
828
  }
829
+ this.watcherReady = false;
385
830
  this.removeAllListeners();
386
831
  }
387
832
  getTrackedFiles() {
388
833
  return Array.from(this.fileHashes.keys());
389
834
  }
390
835
  isCurrentlyRunning() {
391
- return this.isRunning;
836
+ return this.cycleRunning || Boolean(this.lifecycleProcess);
837
+ }
838
+ isStartedProcessRunning() {
839
+ return Boolean(this.startedProcess);
392
840
  }
393
841
  isDisposed() {
394
842
  return this.disposed;
@@ -406,6 +854,7 @@ function steadyWatch(options) {
406
854
  var import_fs2 = __toESM(require("fs"));
407
855
  var import_path2 = __toESM(require("path"));
408
856
  var import_commander = require("commander");
857
+ var CLI_VERSION = "2.1.0";
409
858
  function loadConfig(configPath) {
410
859
  const searchPaths = [
411
860
  configPath,
@@ -447,28 +896,54 @@ function parseNumber(value, defaultValue) {
447
896
  }
448
897
  return defaultValue;
449
898
  }
899
+ function unsetDefaultedCliOptions(program, opts) {
900
+ const getSource = program.getOptionValueSource.bind(program);
901
+ const optionKeys = [
902
+ "delay",
903
+ "verbose",
904
+ "quiet",
905
+ "gitBase",
906
+ "retry",
907
+ "hash",
908
+ "theme"
909
+ ];
910
+ for (const key of optionKeys) {
911
+ if (getSource(key) === "default") {
912
+ delete opts[key];
913
+ }
914
+ }
915
+ }
450
916
  function mergeOptions(cliArgs, cliOpts, config) {
451
917
  return {
452
918
  pattern: cliArgs.pattern || config.pattern || "",
453
919
  cmd: cliOpts.cmd || config.cmd || "",
920
+ start: cliOpts.start || config.start || "",
921
+ restartOnSuccess: parseBoolean(cliOpts.restartOnSuccess ?? config.restartOnSuccess),
922
+ restartOnChange: parseBoolean(cliOpts.restartOnChange ?? config.restartOnChange),
923
+ initialRun: cliOpts.initialRun ?? config.initialRun,
454
924
  delay: parseNumber(cliOpts.delay ?? config.delay, 300),
455
925
  verbose: parseBoolean(cliOpts.verbose ?? config.verbose),
456
926
  quiet: parseBoolean(cliOpts.quiet ?? config.quiet),
457
927
  ignore: [...parseIgnorePatterns(cliOpts.ignore), ...config.ignore || []],
458
928
  ext: cliOpts.ext ? parseExtFilter(cliOpts.ext) : config.ext || [],
459
- killTimeout: parseNumber(cliOpts.killTimeout ?? config.killTimeout, 0),
929
+ gitChanged: parseBoolean(cliOpts.gitChanged ?? config.gitChanged),
930
+ gitBase: cliOpts.gitBase || config.gitBase || "HEAD",
931
+ killTimeout: cliOpts.killTimeout !== void 0 || config.killTimeout !== void 0 ? parseNumber(cliOpts.killTimeout ?? config.killTimeout, 0) : void 0,
932
+ restartDelay: parseNumber(cliOpts.restartDelay ?? config.restartDelay, 0),
460
933
  retry: parseNumber(cliOpts.retry ?? config.retry, 0),
461
- hash: cliOpts.hash || config.hash || "md5",
462
- mtimeOnly: cliOpts.noHash ?? config.mtimeOnly ?? false,
934
+ hash: (typeof cliOpts.hash === "string" ? cliOpts.hash : void 0) || config.hash || "md5",
935
+ mtimeOnly: cliOpts.hash === false || config.mtimeOnly || false,
463
936
  clearScreen: parseBoolean(cliOpts.clear ?? config.clearScreen),
464
937
  json: parseBoolean(cliOpts.json ?? config.json),
465
938
  theme: cliOpts.theme || config.theme || "default"
466
939
  };
467
940
  }
468
- function parseCliArgs() {
469
- import_commander.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();
470
- const cliOpts = import_commander.program.opts();
471
- const cliArgs = { pattern: import_commander.program.args[0] || "" };
941
+ function parseCliArgs(argv = process.argv) {
942
+ const program = new import_commander.Command();
943
+ 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);
944
+ const cliOpts = program.opts();
945
+ unsetDefaultedCliOptions(program, cliOpts);
946
+ const cliArgs = { pattern: program.args[0] || "" };
472
947
  return { args: cliArgs, opts: cliOpts };
473
948
  }
474
949
 
@@ -476,44 +951,48 @@ function parseCliArgs() {
476
951
  function runCli() {
477
952
  const { args, opts } = parseCliArgs();
478
953
  const config = loadConfig(opts.config);
479
- if (opts.config && config.cmd) {
480
- opts.cmd = config.cmd;
481
- }
482
954
  const options = mergeOptions(args, opts, config);
483
- if (!options.cmd) {
484
- console.error("Error: Command is required. Use -c option or config file.");
485
- process.exit(1);
486
- }
487
955
  const watcher = new SteadyWatcher(options);
956
+ let exiting = false;
957
+ const shutdown = async (exitCode, message) => {
958
+ if (exiting) return;
959
+ exiting = true;
960
+ if (message) console.error(message);
961
+ try {
962
+ await watcher.close();
963
+ } finally {
964
+ process.exit(exitCode);
965
+ }
966
+ };
488
967
  watcher.on("error", (err) => {
489
- console.error(`Error: ${err.message}`);
490
- process.exit(1);
968
+ void shutdown(1, `Error: ${err.message}`);
491
969
  });
492
970
  watcher.start().catch((err) => {
493
- console.error(`Failed to start: ${err.message}`);
494
- process.exit(1);
971
+ void shutdown(1, `Failed to start: ${err.message}`);
495
972
  });
496
- const shutdown = async (signal) => {
973
+ const shutdownSignal = async (signal) => {
974
+ if (exiting) return;
497
975
  console.log(`
498
976
  Received ${signal}, shutting down...`);
499
- await watcher.close();
500
- process.exit(0);
977
+ await shutdown(0);
501
978
  };
502
- process.on("SIGINT", () => shutdown("SIGINT"));
503
- process.on("SIGTERM", () => shutdown("SIGTERM"));
979
+ process.on("SIGINT", () => void shutdownSignal("SIGINT"));
980
+ process.on("SIGTERM", () => void shutdownSignal("SIGTERM"));
504
981
  process.on("uncaughtException", (err) => {
505
- console.error("Uncaught exception:", err);
506
- process.exit(1);
982
+ void shutdown(1, `Uncaught exception: ${err instanceof Error ? err.stack || err.message : String(err)}`);
507
983
  });
508
984
  }
509
- var isMain = require.main === module || process.argv[1]?.includes("index.js") || process.argv[1]?.includes("steady-watch");
510
- if (isMain) {
985
+ if (require.main === module) {
511
986
  runCli();
512
987
  }
513
988
  // Annotate the CommonJS export names for ESM import in node:
514
989
  0 && (module.exports = {
990
+ CLI_VERSION,
515
991
  SteadyWatcher,
516
992
  getTheme,
993
+ loadConfig,
994
+ mergeOptions,
995
+ parseCliArgs,
517
996
  runCli,
518
997
  steadyWatch,
519
998
  themes