@trazum/cli 1.41.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.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Where the store actually lives.
3
+ *
4
+ * The core decides what a record is and when two are the same; this decides
5
+ * where the bytes go. Split that way for the reason every module here is:
6
+ * `@trazum/core` stays browser-safe, and the CLI keeps its monopoly on I/O.
7
+ *
8
+ * **Append-only, one buffer per write.** A pull appends a single block and
9
+ * never rewrites what is already there. Two consequences worth stating: a
10
+ * crash during a write loses the tail of one block rather than a year of
11
+ * measurements, and two runs writing at once interleave whole blocks rather
12
+ * than half-lines. Compaction is a separate, explicit errand — `store
13
+ * --prune` — because collapsing a log is the one operation that destroys
14
+ * something, and it should never happen as a side effect of a pull.
15
+ *
16
+ * **A line that will not parse is kept, counted and skipped.** The store is a
17
+ * file a human may open, a backup may truncate and a merge may mangle. Losing
18
+ * the whole month because one line is broken would be the worst possible
19
+ * response; so would silently pretending the month is complete.
20
+ */
21
+
22
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
23
+ import { join } from 'node:path';
24
+ import { resolveStore } from '@trazum/core';
25
+ import type { ResolvedStore, StoreRecord } from '@trazum/core';
26
+
27
+ /** The directory name, relative to wherever the caller roots the store. */
28
+ export const STORE_DIR = '.trazum/store';
29
+
30
+ /** Records are filed by the UTC month their window starts in. */
31
+ function monthOf(record: StoreRecord): string {
32
+ return new Date(record.fromMs).toISOString().slice(0, 7);
33
+ }
34
+
35
+ export interface StoreReadResult {
36
+ resolved: ResolvedStore;
37
+ /** Lines that would not parse: counted and named by file, never dropped quietly. */
38
+ unreadable: { file: string; line: number }[];
39
+ /** Files read, so an empty store can be told from an unread one. */
40
+ files: string[];
41
+ }
42
+
43
+ /**
44
+ * Reads every record in the store.
45
+ *
46
+ * Returns an empty result rather than throwing when the store does not exist:
47
+ * "you have not stored anything yet" is a state, not an error, and the caller
48
+ * says so in a sentence that names `trazum connect`.
49
+ */
50
+ export async function readStore(root: string): Promise<StoreReadResult> {
51
+ const dir = join(root, STORE_DIR);
52
+ const records: StoreRecord[] = [];
53
+ const unreadable: { file: string; line: number }[] = [];
54
+ const files: string[] = [];
55
+
56
+ let providers: string[];
57
+ try {
58
+ const entries = await readdir(dir, { withFileTypes: true });
59
+ providers = entries.filter((e) => e.isDirectory()).map((e) => e.name);
60
+ } catch {
61
+ return { resolved: resolveStore([]), unreadable, files };
62
+ }
63
+
64
+ for (const provider of providers.sort()) {
65
+ const providerDir = join(dir, provider);
66
+ let months: string[];
67
+ try {
68
+ months = (await readdir(providerDir)).filter((name) => name.endsWith('.jsonl')).sort();
69
+ } catch {
70
+ continue;
71
+ }
72
+ for (const month of months) {
73
+ const path = join(providerDir, month);
74
+ files.push(join(STORE_DIR, provider, month));
75
+ const text = await readFile(path, 'utf8');
76
+ for (const [index, line] of text.split('\n').entries()) {
77
+ if (line.trim() === '') continue;
78
+ try {
79
+ const parsed = JSON.parse(line) as StoreRecord;
80
+ if (typeof parsed?.provider === 'string' && typeof parsed?.fromMs === 'number') {
81
+ records.push(parsed);
82
+ } else {
83
+ unreadable.push({ file: join(STORE_DIR, provider, month), line: index + 1 });
84
+ }
85
+ } catch {
86
+ unreadable.push({ file: join(STORE_DIR, provider, month), line: index + 1 });
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ return { resolved: resolveStore(records), unreadable, files };
93
+ }
94
+
95
+ /**
96
+ * Appends records, grouped into one write per month file.
97
+ *
98
+ * Nothing already on disk is read, rewritten or resolved here: convergence
99
+ * happens when the store is *read*, which is what keeps a write cheap enough
100
+ * to run on a schedule and impossible to corrupt by racing.
101
+ */
102
+ export async function appendRecords(root: string, records: readonly StoreRecord[]): Promise<number> {
103
+ if (records.length === 0) return 0;
104
+ const byFile = new Map<string, StoreRecord[]>();
105
+ for (const record of records) {
106
+ const key = join(record.provider, `${monthOf(record)}.jsonl`);
107
+ const list = byFile.get(key) ?? [];
108
+ list.push(record);
109
+ byFile.set(key, list);
110
+ }
111
+
112
+ for (const [relative, list] of byFile) {
113
+ const path = join(root, STORE_DIR, relative);
114
+ await mkdir(join(path, '..'), { recursive: true });
115
+ const block = `${list.map((record) => JSON.stringify(record)).join('\n')}\n`;
116
+ await writeFile(path, block, { flag: 'a', mode: 0o600 });
117
+ }
118
+ return records.length;
119
+ }
120
+
121
+ /**
122
+ * Rewrites the store with exactly the records given.
123
+ *
124
+ * The one operation that destroys something, so it is only ever reached from
125
+ * an explicit `--prune`. Each month file is written whole, and a month left
126
+ * with nothing is written empty rather than removed — a missing file and an
127
+ * empty one say different things to whoever looks next.
128
+ */
129
+ export async function rewriteStore(root: string, records: readonly StoreRecord[]): Promise<void> {
130
+ const dir = join(root, STORE_DIR);
131
+ const existing = new Set<string>();
132
+ try {
133
+ for (const provider of await readdir(dir)) {
134
+ for (const month of await readdir(join(dir, provider)).catch(() => [])) {
135
+ if (month.endsWith('.jsonl')) existing.add(join(provider, month));
136
+ }
137
+ }
138
+ } catch {
139
+ // Nothing stored yet: the writes below create what is needed.
140
+ }
141
+
142
+ const byFile = new Map<string, StoreRecord[]>();
143
+ for (const record of records) {
144
+ const key = join(record.provider, `${monthOf(record)}.jsonl`);
145
+ const list = byFile.get(key) ?? [];
146
+ list.push(record);
147
+ byFile.set(key, list);
148
+ }
149
+
150
+ for (const relative of new Set([...existing, ...byFile.keys()])) {
151
+ const list = byFile.get(relative) ?? [];
152
+ const path = join(dir, relative);
153
+ await mkdir(join(path, '..'), { recursive: true });
154
+ const block = list.length === 0 ? '' : `${list.map((r) => JSON.stringify(r)).join('\n')}\n`;
155
+ await writeFile(path, block, { mode: 0o600 });
156
+ }
157
+ }
@@ -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
+ }