@trazum/cli 1.42.0 → 1.43.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 +1 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +63 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +65 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +26 -1
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +162 -1
- package/dist/index.js.map +1 -1
- package/dist/watch-run.d.ts +69 -0
- package/dist/watch-run.d.ts.map +1 -0
- package/dist/watch-run.js +79 -0
- package/dist/watch-run.js.map +1 -0
- package/package.json +2 -2
- package/src/i18n/en.ts +73 -0
- package/src/i18n/es.ts +75 -0
- package/src/i18n/types.ts +27 -1
- package/src/index.ts +222 -0
- package/src/watch-run.ts +126 -0
package/src/watch-run.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One cycle of watching, and the state that survives a restart.
|
|
3
|
+
*
|
|
4
|
+
* `--once` is the primitive: pull the window, keep it, evaluate the gates,
|
|
5
|
+
* emit what crossed, save state. A cron entry runs exactly that, and so does
|
|
6
|
+
* every test. The foreground loop is this function in a timer, so there is one
|
|
7
|
+
* code path and no daemon-only behaviour that nobody exercises.
|
|
8
|
+
*
|
|
9
|
+
* **The state file is what makes a restart honest.** Without it a resumed
|
|
10
|
+
* watcher re-alerts on yesterday's crossing (noise nobody reads) and implies
|
|
11
|
+
* it was watching the whole time (a claim it cannot make). With it, the
|
|
12
|
+
* crossing stays quiet and the unwatched stretch gets named once.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
import { SAFE_FETCH_INIT } from '@trazum/core/node';
|
|
18
|
+
import type { WatchCrossing } from '@trazum/core';
|
|
19
|
+
|
|
20
|
+
export const WATCH_STATE_FILE = '.trazum/watch.json';
|
|
21
|
+
|
|
22
|
+
export const WATCH_STATE_VERSION = 1;
|
|
23
|
+
|
|
24
|
+
export interface WatchState {
|
|
25
|
+
v: number;
|
|
26
|
+
/** When the last cycle ran, so a long silence can be told from a first run. */
|
|
27
|
+
lastCycleMs: number;
|
|
28
|
+
/** How far the measurements reached, for the coverage gap. */
|
|
29
|
+
lastCoveredToMs: number | null;
|
|
30
|
+
/** Gate keys already alerted on, so a restart is not amnesia. */
|
|
31
|
+
fired: Record<string, number>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function readWatchState(root: string): Promise<WatchState | null> {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(await readFile(join(root, WATCH_STATE_FILE), 'utf8')) as WatchState;
|
|
37
|
+
if (parsed?.v !== WATCH_STATE_VERSION) return null;
|
|
38
|
+
return parsed;
|
|
39
|
+
} catch {
|
|
40
|
+
// No state, or state this version cannot read: a first cycle either way,
|
|
41
|
+
// which is a state the caller reports rather than an error.
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function writeWatchState(root: string, state: WatchState): Promise<void> {
|
|
47
|
+
const path = join(root, WATCH_STATE_FILE);
|
|
48
|
+
await mkdir(dirname(path), { recursive: true });
|
|
49
|
+
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether a webhook URL is one this tool will post to.
|
|
54
|
+
*
|
|
55
|
+
* **This is not the SSRF case and the difference matters.** `checkedEndpoint`
|
|
56
|
+
* exists because a *request body* must never name a host: an anonymous caller
|
|
57
|
+
* pointing a shared server at an internal address is somebody else's machine
|
|
58
|
+
* reaching somewhere it was never meant to. Here the URL is in the operator's
|
|
59
|
+
* own config, on their own machine, and pointing it at their own alerting
|
|
60
|
+
* daemon on loopback is the ordinary case rather than the attack.
|
|
61
|
+
*
|
|
62
|
+
* So loopback is allowed and plain http is allowed *only* there, while two
|
|
63
|
+
* rules stay absolute: no credentials embedded in the URL, because a URL ends
|
|
64
|
+
* up in logs and shell history; and https everywhere else, because an alert
|
|
65
|
+
* carries spend figures across a network.
|
|
66
|
+
*/
|
|
67
|
+
export type WebhookRejection = 'invalid-url' | 'credentials-in-url' | 'insecure-scheme';
|
|
68
|
+
|
|
69
|
+
export function checkWebhook(raw: string): { ok: true; url: URL } | { ok: false; reason: WebhookRejection } {
|
|
70
|
+
let url: URL;
|
|
71
|
+
try {
|
|
72
|
+
url = new URL(raw);
|
|
73
|
+
} catch {
|
|
74
|
+
return { ok: false, reason: 'invalid-url' };
|
|
75
|
+
}
|
|
76
|
+
if (url.username !== '' || url.password !== '') {
|
|
77
|
+
return { ok: false, reason: 'credentials-in-url' };
|
|
78
|
+
}
|
|
79
|
+
const loopback =
|
|
80
|
+
url.hostname === 'localhost' ||
|
|
81
|
+
url.hostname === '127.0.0.1' ||
|
|
82
|
+
url.hostname === '[::1]' ||
|
|
83
|
+
url.hostname === '::1';
|
|
84
|
+
if (url.protocol === 'https:') return { ok: true, url };
|
|
85
|
+
if (url.protocol === 'http:' && loopback) return { ok: true, url };
|
|
86
|
+
return { ok: false, reason: 'insecure-scheme' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The alert payload.
|
|
91
|
+
*
|
|
92
|
+
* Figures and gate names, never prompt text — the store has never held any and
|
|
93
|
+
* neither does this. Every crossing carries its own provenance, so a receiver
|
|
94
|
+
* that fans these into a dashboard cannot lose track of what kind of number it
|
|
95
|
+
* is holding.
|
|
96
|
+
*/
|
|
97
|
+
export interface WatchAlert {
|
|
98
|
+
schemaVersion: 1;
|
|
99
|
+
firedAtMs: number;
|
|
100
|
+
crossings: WatchCrossing[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function postWebhook(
|
|
104
|
+
url: URL,
|
|
105
|
+
alert: WatchAlert,
|
|
106
|
+
fetchImpl: typeof fetch = fetch,
|
|
107
|
+
): Promise<{ ok: boolean; status: number | null; error: string | null }> {
|
|
108
|
+
try {
|
|
109
|
+
const response = await fetchImpl(url.toString(), {
|
|
110
|
+
...SAFE_FETCH_INIT,
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'content-type': 'application/json' },
|
|
113
|
+
body: JSON.stringify(alert),
|
|
114
|
+
signal: AbortSignal.timeout(10_000),
|
|
115
|
+
});
|
|
116
|
+
return { ok: response.ok, status: response.status, error: null };
|
|
117
|
+
} catch (error) {
|
|
118
|
+
/**
|
|
119
|
+
* A webhook that will not deliver must not take the alert down with it.
|
|
120
|
+
* The exit code and the stdout event have already carried the crossing;
|
|
121
|
+
* losing those because a receiver is down would make the quietest failure
|
|
122
|
+
* the loudest one.
|
|
123
|
+
*/
|
|
124
|
+
return { ok: false, status: null, error: error instanceof Error ? error.message : String(error) };
|
|
125
|
+
}
|
|
126
|
+
}
|