@laphilosophia/steady-watch 2.0.2 → 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 });
288
561
  }
289
- this.log(this.t.dim("\u2500".repeat(40)));
290
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);
588
+ }
589
+ });
590
+ }
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();
291
612
  }
292
- handleFileChange(filePath) {
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,14 +854,16 @@ 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,
412
861
  ".steady-watchrc",
413
862
  ".steady-watchrc.json",
414
863
  "steady-watch.config.json"
415
- ].filter(Boolean);
864
+ ];
416
865
  for (const cfgPath of searchPaths) {
866
+ if (!cfgPath) continue;
417
867
  try {
418
868
  const fullPath = import_path2.default.resolve(cfgPath);
419
869
  if (import_fs2.default.existsSync(fullPath)) {
@@ -446,28 +896,54 @@ function parseNumber(value, defaultValue) {
446
896
  }
447
897
  return defaultValue;
448
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
+ }
449
916
  function mergeOptions(cliArgs, cliOpts, config) {
450
917
  return {
451
918
  pattern: cliArgs.pattern || config.pattern || "",
452
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,
453
924
  delay: parseNumber(cliOpts.delay ?? config.delay, 300),
454
925
  verbose: parseBoolean(cliOpts.verbose ?? config.verbose),
455
926
  quiet: parseBoolean(cliOpts.quiet ?? config.quiet),
456
927
  ignore: [...parseIgnorePatterns(cliOpts.ignore), ...config.ignore || []],
457
928
  ext: cliOpts.ext ? parseExtFilter(cliOpts.ext) : config.ext || [],
458
- 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),
459
933
  retry: parseNumber(cliOpts.retry ?? config.retry, 0),
460
- hash: cliOpts.hash || config.hash || "md5",
461
- 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,
462
936
  clearScreen: parseBoolean(cliOpts.clear ?? config.clearScreen),
463
937
  json: parseBoolean(cliOpts.json ?? config.json),
464
938
  theme: cliOpts.theme || config.theme || "default"
465
939
  };
466
940
  }
467
- function parseCliArgs() {
468
- 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")').requiredOption("-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();
469
- const cliOpts = import_commander.program.opts();
470
- 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] || "" };
471
947
  return { args: cliArgs, opts: cliOpts };
472
948
  }
473
949
 
@@ -475,44 +951,48 @@ function parseCliArgs() {
475
951
  function runCli() {
476
952
  const { args, opts } = parseCliArgs();
477
953
  const config = loadConfig(opts.config);
478
- if (opts.config && config.cmd) {
479
- opts.cmd = config.cmd;
480
- }
481
954
  const options = mergeOptions(args, opts, config);
482
- if (!options.cmd) {
483
- console.error("Error: Command is required. Use -c option or config file.");
484
- process.exit(1);
485
- }
486
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
+ };
487
967
  watcher.on("error", (err) => {
488
- console.error(`Error: ${err.message}`);
489
- process.exit(1);
968
+ void shutdown(1, `Error: ${err.message}`);
490
969
  });
491
970
  watcher.start().catch((err) => {
492
- console.error(`Failed to start: ${err.message}`);
493
- process.exit(1);
971
+ void shutdown(1, `Failed to start: ${err.message}`);
494
972
  });
495
- const shutdown = async (signal) => {
973
+ const shutdownSignal = async (signal) => {
974
+ if (exiting) return;
496
975
  console.log(`
497
976
  Received ${signal}, shutting down...`);
498
- await watcher.close();
499
- process.exit(0);
977
+ await shutdown(0);
500
978
  };
501
- process.on("SIGINT", () => shutdown("SIGINT"));
502
- process.on("SIGTERM", () => shutdown("SIGTERM"));
979
+ process.on("SIGINT", () => void shutdownSignal("SIGINT"));
980
+ process.on("SIGTERM", () => void shutdownSignal("SIGTERM"));
503
981
  process.on("uncaughtException", (err) => {
504
- console.error("Uncaught exception:", err);
505
- process.exit(1);
982
+ void shutdown(1, `Uncaught exception: ${err instanceof Error ? err.stack || err.message : String(err)}`);
506
983
  });
507
984
  }
508
- var isMain = require.main === module || process.argv[1]?.includes("index.js") || process.argv[1]?.includes("steady-watch");
509
- if (isMain) {
985
+ if (require.main === module) {
510
986
  runCli();
511
987
  }
512
988
  // Annotate the CommonJS export names for ESM import in node:
513
989
  0 && (module.exports = {
990
+ CLI_VERSION,
514
991
  SteadyWatcher,
515
992
  getTheme,
993
+ loadConfig,
994
+ mergeOptions,
995
+ parseCliArgs,
516
996
  runCli,
517
997
  steadyWatch,
518
998
  themes