omkit 0.0.1

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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/dist/actions/chrome-page.d.ts +44 -0
  4. package/dist/actions/chrome-page.js +94 -0
  5. package/dist/actions/command.d.ts +26 -0
  6. package/dist/actions/command.js +32 -0
  7. package/dist/actions/healthcheck.d.ts +55 -0
  8. package/dist/actions/healthcheck.js +94 -0
  9. package/dist/actions/index.d.ts +22 -0
  10. package/dist/actions/index.js +14 -0
  11. package/dist/actions/prompt.d.ts +54 -0
  12. package/dist/actions/prompt.js +99 -0
  13. package/dist/actions/tail-log.d.ts +30 -0
  14. package/dist/actions/tail-log.js +124 -0
  15. package/dist/actions/until-log.d.ts +24 -0
  16. package/dist/actions/until-log.js +49 -0
  17. package/dist/actions/watch-dir.d.ts +35 -0
  18. package/dist/actions/watch-dir.js +159 -0
  19. package/dist/actions/watch.d.ts +33 -0
  20. package/dist/actions/watch.js +80 -0
  21. package/dist/core/action.d.ts +105 -0
  22. package/dist/core/action.js +133 -0
  23. package/dist/core/events.d.ts +23 -0
  24. package/dist/core/events.js +61 -0
  25. package/dist/core/helpers/AsyncQueue.d.ts +13 -0
  26. package/dist/core/helpers/AsyncQueue.js +50 -0
  27. package/dist/core/log-collector/LogsCollector.d.ts +63 -0
  28. package/dist/core/log-collector/LogsCollector.js +110 -0
  29. package/dist/core/log-collector/global.d.ts +7 -0
  30. package/dist/core/log-collector/global.js +7 -0
  31. package/dist/core/log-collector/render.d.ts +57 -0
  32. package/dist/core/log-collector/render.js +111 -0
  33. package/dist/core/markers.d.ts +9 -0
  34. package/dist/core/markers.js +13 -0
  35. package/dist/core/output.d.ts +55 -0
  36. package/dist/core/output.js +163 -0
  37. package/dist/core/process.d.ts +21 -0
  38. package/dist/core/process.js +73 -0
  39. package/dist/core/run.d.ts +95 -0
  40. package/dist/core/run.js +212 -0
  41. package/dist/core/spin.d.ts +58 -0
  42. package/dist/core/spin.js +29 -0
  43. package/dist/index.d.ts +25 -0
  44. package/dist/index.js +25 -0
  45. package/package.json +59 -0
@@ -0,0 +1,99 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { action } from "../core/action.js";
3
+ /** Normalize choices to `{ label, value }`. */
4
+ const asChoices = (choices) => choices.map((c) => (typeof c === "string" ? { label: c, value: c } : c));
5
+ /**
6
+ * Asks the terminal a question — free-text **input** or a **choice** from a list —
7
+ * and resolves with the answer, which it also publishes as the action's handle
8
+ * (so `prompt(...).ref` feeds straight into another action). Everything is
9
+ * defaulted: a bare `prompt()` is a free-text input with no default and a 30s
10
+ * timeout.
11
+ *
12
+ * A configurable timeout (default 30s; non-finite/≤0 waits forever) falls back to
13
+ * the default value, so an unattended run never blocks. The run's abort signal
14
+ * also unblocks a pending prompt on teardown. Emits `prompt` when shown and
15
+ * `answer` when settled (carrying whether it came from input, the default, or a
16
+ * timeout).
17
+ */
18
+ export const prompt = action("Prompt")
19
+ .emits()
20
+ .ref()
21
+ .run(async ({ logs, signal, emit, attach }, opts = {}) => {
22
+ const timeoutMs = opts.timeoutMs ?? 30_000;
23
+ const finite = Number.isFinite(timeoutMs) && timeoutMs > 0;
24
+ // Build the question, the fallback value, and a resolver from raw input.
25
+ let message;
26
+ let query;
27
+ let defaultValue;
28
+ let resolveRaw;
29
+ if (opts.kind === "choice") {
30
+ const choices = asChoices(opts.choices);
31
+ const { default: def } = opts;
32
+ const picked = typeof def === "number"
33
+ ? choices[def - 1]
34
+ : def !== undefined
35
+ ? choices.find((c) => c.value === def)
36
+ : undefined;
37
+ defaultValue = (picked ?? choices[0])?.value ?? "";
38
+ message = opts.message ?? "Choose:";
39
+ const list = choices
40
+ .map((c, i) => ` ${i + 1}) ${c.label}${c.value === defaultValue ? " (default)" : ""}`)
41
+ .join("\n");
42
+ query = `${message}\n${list}\n> `;
43
+ // Accept a 1-based index, an exact value, or an exact label.
44
+ resolveRaw = (raw) => {
45
+ const n = Number(raw);
46
+ if (Number.isInteger(n) && n >= 1 && n <= choices.length)
47
+ return choices[n - 1].value;
48
+ return choices.find((c) => c.value === raw || c.label === raw)?.value;
49
+ };
50
+ }
51
+ else {
52
+ defaultValue = opts.default ?? "";
53
+ message = opts.message ?? "Input:";
54
+ query = defaultValue ? `${message} (${defaultValue}) ` : `${message} `;
55
+ resolveRaw = (raw) => raw; // any non-empty input is the value
56
+ }
57
+ emit("prompt", message);
58
+ // A dedicated controller for the timeout, so it's distinguishable from a
59
+ // teardown abort; either one cancels the pending question.
60
+ const onTimeout = new AbortController();
61
+ const timer = finite ? setTimeout(() => onTimeout.abort(), timeoutMs) : undefined;
62
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
63
+ let value = defaultValue;
64
+ let via = "default";
65
+ try {
66
+ const raw = (await rl.question(query, { signal: AbortSignal.any([signal, onTimeout.signal]) })).trim();
67
+ if (raw === "") {
68
+ via = "default";
69
+ }
70
+ else {
71
+ const resolved = resolveRaw(raw);
72
+ if (resolved !== undefined) {
73
+ value = resolved;
74
+ via = "input";
75
+ }
76
+ else {
77
+ logs.append({ source: "Prompt", level: "info", message: `invalid answer "${raw}", using default` });
78
+ via = "default";
79
+ }
80
+ }
81
+ }
82
+ catch {
83
+ // Aborted before an answer: the timeout (use default + note it) or teardown.
84
+ via = onTimeout.signal.aborted ? "timeout" : "default";
85
+ }
86
+ finally {
87
+ if (timer)
88
+ clearTimeout(timer);
89
+ rl.close();
90
+ }
91
+ logs.append({
92
+ source: "Prompt",
93
+ level: "info",
94
+ message: via === "timeout" ? `timed out → ${value}` : `${value} (${via})`,
95
+ });
96
+ attach(value); // resolves instance.ref → the answer
97
+ emit("answer", { value, via });
98
+ return value;
99
+ });
@@ -0,0 +1,30 @@
1
+ export interface TailLogOptions {
2
+ /** Label the lines are logged under. Default: the file's basename. */
3
+ source?: string;
4
+ /** Level for appended lines. Default "info". */
5
+ level?: string;
6
+ /**
7
+ * Replay the file's existing content first. Default false — start at the
8
+ * current end and only stream what's appended from now on (like `tail -f`).
9
+ */
10
+ fromStart?: boolean;
11
+ /** Text encoding of the file. Default "utf8". */
12
+ encoding?: BufferEncoding;
13
+ /** Coalesce rapid writes within this window. Default 50ms. */
14
+ debounceMs?: number;
15
+ }
16
+ /**
17
+ * Tails a (possibly not-yet-existing) text/log file and streams each appended
18
+ * line into the run's combined log — the way `proc` streams a child's stdout, but
19
+ * for a file some *other* process writes. Useful for folding an external tool's
20
+ * own log file into the one timeline.
21
+ *
22
+ * It tracks a byte offset and reads only the delta on each change, buffering a
23
+ * partial trailing line until its newline arrives (so a line split across two
24
+ * writes is logged once, whole). A shrink in size is treated as truncation or
25
+ * rotation — the offset resets to 0 and reading resumes from the top. Since
26
+ * `fs.watch` can't watch a missing path, it watches the parent directory and
27
+ * filters by name, like {@link import("./watch.ts").watch}. Runs until the run's
28
+ * signal aborts, then closes the watcher (flushing any trailing partial line).
29
+ */
30
+ export declare const tailLog: import("../core/action.ts").Action<[target: string, opts?: TailLogOptions | undefined], void, import("../core/action.ts").NoEvents, void>;
@@ -0,0 +1,124 @@
1
+ import { watch as fsWatch } from "node:fs";
2
+ import { open, stat } from "node:fs/promises";
3
+ import { basename, dirname, resolve } from "node:path";
4
+ import { action } from "../core/action.js";
5
+ /**
6
+ * Tails a (possibly not-yet-existing) text/log file and streams each appended
7
+ * line into the run's combined log — the way `proc` streams a child's stdout, but
8
+ * for a file some *other* process writes. Useful for folding an external tool's
9
+ * own log file into the one timeline.
10
+ *
11
+ * It tracks a byte offset and reads only the delta on each change, buffering a
12
+ * partial trailing line until its newline arrives (so a line split across two
13
+ * writes is logged once, whole). A shrink in size is treated as truncation or
14
+ * rotation — the offset resets to 0 and reading resumes from the top. Since
15
+ * `fs.watch` can't watch a missing path, it watches the parent directory and
16
+ * filters by name, like {@link import("./watch.ts").watch}. Runs until the run's
17
+ * signal aborts, then closes the watcher (flushing any trailing partial line).
18
+ */
19
+ export const tailLog = action("Tail Log").run(async ({ logs, signal }, target, opts = {}) => {
20
+ const file = resolve(target);
21
+ const dir = dirname(file);
22
+ const name = basename(file);
23
+ const { source = name, level = "info", fromStart = false, encoding = "utf8", debounceMs = 50 } = opts;
24
+ let pos = 0; // bytes consumed so far
25
+ let pending = ""; // partial trailing line, buffered until its newline
26
+ // Append every complete line in `text`, keeping any remainder for next time.
27
+ const feed = (text) => {
28
+ pending += text;
29
+ let newline;
30
+ while ((newline = pending.indexOf("\n")) !== -1) {
31
+ const line = pending.slice(0, newline).replace(/\r$/, "");
32
+ pending = pending.slice(newline + 1);
33
+ if (line)
34
+ logs.append({ source, level, message: line });
35
+ }
36
+ };
37
+ // Read [pos, size) and feed it. Catches the file being absent (rotated away)
38
+ // and resets on a shrink (truncation/rotation), so reading self-heals.
39
+ const readDelta = async () => {
40
+ let size;
41
+ try {
42
+ size = (await stat(file)).size;
43
+ }
44
+ catch {
45
+ return; // missing/unreadable — wait for the next event
46
+ }
47
+ if (size < pos) {
48
+ pos = 0;
49
+ pending = "";
50
+ }
51
+ if (size <= pos)
52
+ return;
53
+ const handle = await open(file, "r");
54
+ try {
55
+ const length = size - pos;
56
+ const buffer = Buffer.alloc(length);
57
+ await handle.read(buffer, 0, length, pos);
58
+ pos = size;
59
+ feed(buffer.toString(encoding));
60
+ }
61
+ finally {
62
+ await handle.close();
63
+ }
64
+ };
65
+ // Serialize reads so a burst of events can't overlap; coalesce extra triggers
66
+ // into a single follow-up pass.
67
+ let reading = false;
68
+ let again = false;
69
+ const pump = () => {
70
+ if (reading) {
71
+ again = true;
72
+ return;
73
+ }
74
+ reading = true;
75
+ void (async () => {
76
+ try {
77
+ do {
78
+ again = false;
79
+ await readDelta();
80
+ } while (again);
81
+ }
82
+ finally {
83
+ reading = false;
84
+ }
85
+ })();
86
+ };
87
+ let timer;
88
+ const schedule = () => {
89
+ if (timer)
90
+ clearTimeout(timer);
91
+ timer = setTimeout(pump, debounceMs);
92
+ };
93
+ // Seed the offset: from the top when replaying, otherwise from the current end
94
+ // so existing content is skipped. A missing file starts at 0 — its content
95
+ // counts as "appended" once it appears.
96
+ try {
97
+ pos = fromStart ? 0 : (await stat(file)).size;
98
+ }
99
+ catch {
100
+ pos = 0;
101
+ }
102
+ if (fromStart)
103
+ pump();
104
+ const watcher = fsWatch(dir, (_event, filename) => {
105
+ if (filename === null || filename === name)
106
+ schedule();
107
+ });
108
+ // Daemon: stay alive until torn down, then stop and flush any partial line.
109
+ return new Promise((resolveRun) => {
110
+ const stop = () => {
111
+ if (timer)
112
+ clearTimeout(timer);
113
+ watcher.close();
114
+ const tail = pending.replace(/\r$/, "");
115
+ if (tail)
116
+ logs.append({ source, level, message: tail });
117
+ resolveRun();
118
+ };
119
+ if (signal.aborted)
120
+ stop();
121
+ else
122
+ signal.addEventListener("abort", stop, { once: true });
123
+ });
124
+ });
@@ -0,0 +1,24 @@
1
+ import type { LogEntry } from "../core/log-collector/LogsCollector.ts";
2
+ /** Predicate over a log entry — return `true` for the entry to wait for. */
3
+ export type LogMatcher = (entry: LogEntry) => boolean;
4
+ export interface UntilLogOptions {
5
+ /**
6
+ * Reject if no entry matches within this many milliseconds. Omit to wait
7
+ * indefinitely (the caller is then responsible for guaranteeing a match).
8
+ */
9
+ timeoutMs?: number;
10
+ /** Replay the buffered history before live entries (catch already-logged matches). */
11
+ replay?: boolean;
12
+ }
13
+ /** Events emitted by {@link untilLog}. */
14
+ export interface UntilLogEvents {
15
+ /** Fired with the matching entry the moment it's found, just before resolving. */
16
+ match: LogEntry;
17
+ }
18
+ /**
19
+ * Gate: resolves with the first log entry the `matcher` accepts (and emits
20
+ * `match`). Subscribes to the injected collector and races each read against the
21
+ * run's abort signal (and an optional timeout), so teardown unblocks a pending
22
+ * wait instead of hanging.
23
+ */
24
+ export declare const untilLog: import("../core/action.ts").Action<[matcher: LogMatcher, (UntilLogOptions | undefined)?], LogEntry, UntilLogEvents, void>;
@@ -0,0 +1,49 @@
1
+ import { action } from "../core/action.js";
2
+ /**
3
+ * Gate: resolves with the first log entry the `matcher` accepts (and emits
4
+ * `match`). Subscribes to the injected collector and races each read against the
5
+ * run's abort signal (and an optional timeout), so teardown unblocks a pending
6
+ * wait instead of hanging.
7
+ */
8
+ export const untilLog = action("Until Log")
9
+ .emits()
10
+ .run(({ logs, signal, emit }, matcher, { timeoutMs, replay = false } = {}) => {
11
+ const iterator = logs.subscribe({ replay })[Symbol.asyncIterator]();
12
+ let timer;
13
+ const deadline = timeoutMs === undefined
14
+ ? undefined
15
+ : new Promise((_, reject) => {
16
+ timer = setTimeout(() => reject(new Error(`untilLog: no match within ${timeoutMs}ms`)), timeoutMs);
17
+ });
18
+ let onAbort;
19
+ const aborted = new Promise((_, reject) => {
20
+ if (signal.aborted)
21
+ return reject(new Error("untilLog: aborted"));
22
+ onAbort = () => reject(new Error("untilLog: aborted"));
23
+ signal.addEventListener("abort", onAbort, { once: true });
24
+ });
25
+ const scan = async () => {
26
+ try {
27
+ for (;;) {
28
+ const racers = [iterator.next(), aborted];
29
+ if (deadline)
30
+ racers.push(deadline);
31
+ const result = await Promise.race(racers);
32
+ if (result.done)
33
+ throw new Error("untilLog: stream ended before a match");
34
+ if (matcher(result.value)) {
35
+ emit("match", result.value);
36
+ return result.value;
37
+ }
38
+ }
39
+ }
40
+ finally {
41
+ if (timer)
42
+ clearTimeout(timer);
43
+ if (onAbort)
44
+ signal.removeEventListener("abort", onAbort);
45
+ await iterator.return?.();
46
+ }
47
+ };
48
+ return scan();
49
+ });
@@ -0,0 +1,35 @@
1
+ export interface WatchDirOptions {
2
+ /** Coalesce rapid events within this window. Default 50ms. */
3
+ debounceMs?: number;
4
+ }
5
+ /**
6
+ * Events emitted by {@link watchDir}. Each payload is the absolute path of the
7
+ * entry — a file for create/update/delete, the folder itself for ready/removed.
8
+ */
9
+ export interface WatchDirEvents {
10
+ /** A file appeared during the run. */
11
+ create: string;
12
+ /** A file's contents changed (its mtime moved). */
13
+ update: string;
14
+ /** A file was removed. */
15
+ delete: string;
16
+ /** The folder appeared (or existed at startup); fires again on recreation. */
17
+ ready: string;
18
+ /** The folder was removed (paired with `ready` when it's replaced). */
19
+ removed: string;
20
+ }
21
+ /**
22
+ * Watches a directory and emits per-file events on changes, surviving the folder
23
+ * being deleted and recreated (e.g. a build that `rm -rf`s it first).
24
+ *
25
+ * It watches the existing ancestor chain (target recursively, ancestors
26
+ * non-recursively) so a stable ancestor keeps firing when the target reappears.
27
+ * Per-file changes are derived by re-`stat`ing and diffing mtimes.
28
+ *
29
+ * The folder's own removal/recreation can't be seen by stat: a wipe+recreate is
30
+ * atomic (never observed absent), and on NTFS the recreated dir even keeps the
31
+ * old `ino`/`birthtime` (tunneling). So that's detected from the raw `rename`
32
+ * event the *parent* fires for the target's name — when it does and the target
33
+ * still exists, it was replaced (`removed` + `ready`).
34
+ */
35
+ export declare const watchDir: import("../core/action.ts").Action<[target: string, opts?: WatchDirOptions | undefined], void, WatchDirEvents, void>;
@@ -0,0 +1,159 @@
1
+ import { existsSync, watch as fsWatch } from "node:fs";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { action } from "../core/action.js";
5
+ /**
6
+ * Watches a directory and emits per-file events on changes, surviving the folder
7
+ * being deleted and recreated (e.g. a build that `rm -rf`s it first).
8
+ *
9
+ * It watches the existing ancestor chain (target recursively, ancestors
10
+ * non-recursively) so a stable ancestor keeps firing when the target reappears.
11
+ * Per-file changes are derived by re-`stat`ing and diffing mtimes.
12
+ *
13
+ * The folder's own removal/recreation can't be seen by stat: a wipe+recreate is
14
+ * atomic (never observed absent), and on NTFS the recreated dir even keeps the
15
+ * old `ino`/`birthtime` (tunneling). So that's detected from the raw `rename`
16
+ * event the *parent* fires for the target's name — when it does and the target
17
+ * still exists, it was replaced (`removed` + `ready`).
18
+ */
19
+ export const watchDir = action("Watch Dir")
20
+ .emits()
21
+ .run(({ signal, emit }, target, opts = {}) => {
22
+ const { debounceMs = 50 } = opts;
23
+ const root = resolve(target);
24
+ const targetName = basename(root);
25
+ const targetParent = dirname(root);
26
+ let seen = new Map(); // abs path -> mtimeMs
27
+ let rootPresent = false; // believed existence of the folder itself
28
+ let churned = false; // the parent reported a rename for the target since last tick
29
+ const scan = async () => {
30
+ const next = new Map();
31
+ let entries;
32
+ try {
33
+ entries = await readdir(root, { recursive: true });
34
+ }
35
+ catch {
36
+ return next; // target missing/unreadable -> empty snapshot
37
+ }
38
+ for (const rel of entries) {
39
+ const abs = join(root, rel);
40
+ try {
41
+ const stats = await stat(abs);
42
+ if (stats.isFile())
43
+ next.set(abs, stats.mtimeMs);
44
+ }
45
+ catch {
46
+ // entry vanished between readdir and stat — skip
47
+ }
48
+ }
49
+ return next;
50
+ };
51
+ const recheck = async (isInitial) => {
52
+ const next = await scan();
53
+ // Startup seeds the snapshot silently — so onCreate means "created during
54
+ // this run", not "already there when we started" (last run's leftovers).
55
+ if (!isInitial) {
56
+ for (const [path, mtime] of next) {
57
+ const prev = seen.get(path);
58
+ if (prev === undefined)
59
+ emit("create", path);
60
+ else if (prev !== mtime)
61
+ emit("update", path);
62
+ }
63
+ for (const path of seen.keys()) {
64
+ if (!next.has(path))
65
+ emit("delete", path);
66
+ }
67
+ }
68
+ seen = next;
69
+ };
70
+ // Derive the folder's own create/remove/recreate from existence + the churn
71
+ // signal (a parent `rename` for the target's name).
72
+ const detectDir = (isInitial, didChurn) => {
73
+ const present = existsSync(root);
74
+ if (isInitial) {
75
+ rootPresent = present;
76
+ if (present)
77
+ emit("ready", root);
78
+ return;
79
+ }
80
+ if (didChurn) {
81
+ if (rootPresent)
82
+ emit("removed", root); // replaced or removed
83
+ if (present)
84
+ emit("ready", root); // created or recreated
85
+ }
86
+ else if (present !== rootPresent) {
87
+ emit(present ? "ready" : "removed", root);
88
+ }
89
+ rootPresent = present;
90
+ };
91
+ const existingChain = () => {
92
+ const chain = [];
93
+ for (let dir = root;; dir = dirname(dir)) {
94
+ if (existsSync(dir))
95
+ chain.push(dir);
96
+ if (dirname(dir) === dir)
97
+ break;
98
+ }
99
+ return chain;
100
+ };
101
+ const watchers = [];
102
+ let armed = [];
103
+ const onEvent = (dir) => (eventType, filename) => {
104
+ // A rename of the target's own name, seen from its parent = the folder
105
+ // was created / removed / replaced.
106
+ if (dir === targetParent && filename === targetName && eventType === "rename") {
107
+ churned = true;
108
+ }
109
+ schedule();
110
+ };
111
+ const arm = (force) => {
112
+ const chain = existingChain();
113
+ const same = chain.length === armed.length && chain.every((d, i) => d === armed[i]);
114
+ if (same && !force)
115
+ return; // re-arm only when structure changed (or forced after a churn)
116
+ for (const w of watchers)
117
+ w.close();
118
+ watchers.length = 0;
119
+ for (const dir of chain) {
120
+ try {
121
+ watchers.push(fsWatch(dir, { recursive: dir === root }, onEvent(dir)));
122
+ }
123
+ catch {
124
+ // some ancestors may be unwatchable — ignore
125
+ }
126
+ }
127
+ armed = chain;
128
+ };
129
+ let timer;
130
+ const schedule = () => {
131
+ if (timer)
132
+ clearTimeout(timer);
133
+ timer = setTimeout(() => void tick(false), debounceMs);
134
+ };
135
+ const tick = async (isInitial) => {
136
+ const didChurn = churned;
137
+ churned = false;
138
+ arm(didChurn); // re-arm (force after a churn — the old target handle is dead)
139
+ detectDir(isInitial, didChurn);
140
+ await recheck(isInitial);
141
+ };
142
+ void tick(true); // initial state + arm the chain
143
+ // Daemon: stay alive until torn down, then flush once more and close.
144
+ return new Promise((resolveRun) => {
145
+ const stop = async () => {
146
+ if (timer)
147
+ clearTimeout(timer);
148
+ await tick(false); // final flush — capture changes right before teardown
149
+ for (const w of watchers)
150
+ w.close();
151
+ watchers.length = 0;
152
+ resolveRun();
153
+ };
154
+ if (signal.aborted)
155
+ void stop();
156
+ else
157
+ signal.addEventListener("abort", () => void stop(), { once: true });
158
+ });
159
+ });
@@ -0,0 +1,33 @@
1
+ import { type Stats } from "node:fs";
2
+ export interface FileEvent {
3
+ /** Absolute path of the watched file. */
4
+ path: string;
5
+ /** True for the state reported at startup; false for a live transition. */
6
+ isInitial: boolean;
7
+ /** Stats when the file exists (create/update); absent for delete/missing. */
8
+ stats?: Stats;
9
+ }
10
+ export type FileListener = (event: FileEvent) => void;
11
+ export interface WatchOptions {
12
+ /** The file is absent when watching starts. */
13
+ onMissing?: FileListener;
14
+ /** The file appeared (or already existed at startup — `isInitial`). */
15
+ onCreate?: FileListener;
16
+ /** The file's contents changed (its mtime moved). */
17
+ onUpdate?: FileListener;
18
+ /** The file was removed. */
19
+ onDelete?: FileListener;
20
+ /** Coalesce rapid events (e.g. an editor's atomic save) within this window. Default 50ms. */
21
+ debounceMs?: number;
22
+ }
23
+ /**
24
+ * Watches a single file that may not exist yet, firing callbacks on its
25
+ * transitions. Since `fs.watch` can't watch a missing path, it watches the
26
+ * parent directory and filters by name; on each event it re-`stat`s and diffs
27
+ * against the last known state rather than trusting the raw event type. Runs
28
+ * until the run's signal aborts, then closes the watcher.
29
+ *
30
+ * Assumes the parent directory exists; watching when an ancestor is missing is
31
+ * a later case.
32
+ */
33
+ export declare const watch: import("../core/action.ts").Action<[target: string, opts?: WatchOptions | undefined], void, import("../core/action.ts").NoEvents, void>;
@@ -0,0 +1,80 @@
1
+ import { watch as fsWatch } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import { basename, dirname, resolve } from "node:path";
4
+ import { action } from "../core/action.js";
5
+ /**
6
+ * Watches a single file that may not exist yet, firing callbacks on its
7
+ * transitions. Since `fs.watch` can't watch a missing path, it watches the
8
+ * parent directory and filters by name; on each event it re-`stat`s and diffs
9
+ * against the last known state rather than trusting the raw event type. Runs
10
+ * until the run's signal aborts, then closes the watcher.
11
+ *
12
+ * Assumes the parent directory exists; watching when an ancestor is missing is
13
+ * a later case.
14
+ */
15
+ export const watch = action("Watch").run(({ logs, signal }, target, opts = {}) => {
16
+ const { onMissing, onCreate, onUpdate, onDelete, debounceMs = 50 } = opts;
17
+ const file = resolve(target);
18
+ const dir = dirname(file);
19
+ const name = basename(file);
20
+ let present = false;
21
+ let mtimeMs = 0;
22
+ const recheck = async (isInitial) => {
23
+ let stats;
24
+ try {
25
+ stats = await stat(file);
26
+ }
27
+ catch {
28
+ stats = undefined; // ENOENT (or unreadable) → treat as absent
29
+ }
30
+ if (stats?.isFile()) {
31
+ if (!present) {
32
+ present = true;
33
+ mtimeMs = stats.mtimeMs;
34
+ logs.append({ source: name, level: "info", message: `created ${file}` });
35
+ onCreate?.({ path: file, isInitial, stats });
36
+ }
37
+ else if (stats.mtimeMs !== mtimeMs) {
38
+ mtimeMs = stats.mtimeMs;
39
+ logs.append({ source: name, level: "info", message: `updated ${file}` });
40
+ onUpdate?.({ path: file, isInitial, stats });
41
+ }
42
+ }
43
+ else if (present) {
44
+ present = false;
45
+ mtimeMs = 0;
46
+ logs.append({ source: name, level: "info", message: `deleted ${file}` });
47
+ onDelete?.({ path: file, isInitial });
48
+ }
49
+ else if (isInitial) {
50
+ logs.append({ source: name, level: "info", message: `missing ${file}` });
51
+ onMissing?.({ path: file, isInitial });
52
+ }
53
+ };
54
+ let timer;
55
+ const schedule = () => {
56
+ if (timer)
57
+ clearTimeout(timer);
58
+ timer = setTimeout(() => void recheck(false), debounceMs);
59
+ };
60
+ // Watch the parent dir (the file may not exist yet) and filter by name.
61
+ // Some platforms pass a null filename — recheck anyway, it's a cheap stat.
62
+ const watcher = fsWatch(dir, (_event, filename) => {
63
+ if (filename === null || filename === name)
64
+ schedule();
65
+ });
66
+ void recheck(true); // report the initial state
67
+ // Daemon: stay alive until torn down, then close the watcher.
68
+ return new Promise((resolveRun) => {
69
+ const stop = () => {
70
+ if (timer)
71
+ clearTimeout(timer);
72
+ watcher.close();
73
+ resolveRun();
74
+ };
75
+ if (signal.aborted)
76
+ stop();
77
+ else
78
+ signal.addEventListener("abort", stop, { once: true });
79
+ });
80
+ });