@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/README.md +76 -2
- package/bin/steady-watch +1 -1
- package/dist/index.d.mts +107 -7
- package/dist/index.d.ts +107 -7
- package/dist/index.js +598 -119
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +594 -119
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -5
- package/scripts/smoke-tests.mjs +277 -0
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.
|
|
60
|
-
this.
|
|
61
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
this.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
this.
|
|
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
|
-
|
|
269
|
-
|
|
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("
|
|
276
|
-
this.logJson("change", { file: path.basename(filePath) });
|
|
277
|
-
this.
|
|
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.
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
341
|
-
clearTimeout(this.
|
|
342
|
-
this.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
457
|
-
process.exit(1);
|
|
931
|
+
void shutdown(1, `Error: ${err.message}`);
|
|
458
932
|
});
|
|
459
933
|
watcher.start().catch((err) => {
|
|
460
|
-
|
|
461
|
-
process.exit(1);
|
|
934
|
+
void shutdown(1, `Failed to start: ${err.message}`);
|
|
462
935
|
});
|
|
463
|
-
const
|
|
936
|
+
const shutdownSignal = async (signal) => {
|
|
937
|
+
if (exiting) return;
|
|
464
938
|
console.log(`
|
|
465
939
|
Received ${signal}, shutting down...`);
|
|
466
|
-
await
|
|
467
|
-
process.exit(0);
|
|
940
|
+
await shutdown(0);
|
|
468
941
|
};
|
|
469
|
-
process.on("SIGINT", () =>
|
|
470
|
-
process.on("SIGTERM", () =>
|
|
942
|
+
process.on("SIGINT", () => void shutdownSignal("SIGINT"));
|
|
943
|
+
process.on("SIGTERM", () => void shutdownSignal("SIGTERM"));
|
|
471
944
|
process.on("uncaughtException", (err) => {
|
|
472
|
-
|
|
473
|
-
process.exit(1);
|
|
945
|
+
void shutdown(1, `Uncaught exception: ${err instanceof Error ? err.stack || err.message : String(err)}`);
|
|
474
946
|
});
|
|
475
947
|
}
|
|
476
|
-
|
|
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
|