@tokenoftrust/cli 2.0.3 → 2.0.4
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/package.json +1 -1
- package/src/machine-id.mjs +72 -0
- package/src/telemetry.mjs +126 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.4",
|
|
4
4
|
"description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stable per-machine identifier, persisted in `~/.tot/config.json` alongside
|
|
3
|
+
* whatever else already lives there. Read/write is additive — we merge just the
|
|
4
|
+
* `machineId` key in, so other tools' fields (e.g. `runtimes`) ride along
|
|
5
|
+
* untouched, same philosophy as token-store's schema-free credential file.
|
|
6
|
+
*
|
|
7
|
+
* Not a secret, but kept 0600 in a 0700 dir like the rest of `~/.tot` for
|
|
8
|
+
* consistency. `TOT_HOME` overrides the home dir (tests point it at a temp dir),
|
|
9
|
+
* like activity-log and token-store.
|
|
10
|
+
*
|
|
11
|
+
* Best-effort by contract: never throws. A persist failure still returns a
|
|
12
|
+
* usable (if unpersisted) id for the current call.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { homedir, hostname } from "node:os";
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
import { join, dirname } from "node:path";
|
|
20
|
+
|
|
21
|
+
/** Absolute path to the machine-level config for this environment. */
|
|
22
|
+
export function configPath(env = process.env) {
|
|
23
|
+
const home = env.TOT_HOME || homedir();
|
|
24
|
+
return join(home, ".tot", "config.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Read the parsed config, or {} if absent/unreadable/malformed. Never throws. */
|
|
28
|
+
export function readConfig(env = process.env) {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(readFileSync(configPath(env), "utf8"));
|
|
31
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
32
|
+
} catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Merge `patch` into the existing config and write it back atomically (temp-file
|
|
39
|
+
* rename, 0600), preserving keys `patch` doesn't mention. Returns the merged
|
|
40
|
+
* config, or null on failure. Never throws.
|
|
41
|
+
*/
|
|
42
|
+
export function writeConfig(patch, env = process.env) {
|
|
43
|
+
try {
|
|
44
|
+
const filePath = configPath(env);
|
|
45
|
+
const merged = { ...readConfig(env), ...patch };
|
|
46
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
47
|
+
const tmp = `${filePath}.tmp`;
|
|
48
|
+
writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
|
|
49
|
+
renameSync(tmp, filePath);
|
|
50
|
+
chmodSync(filePath, 0o600);
|
|
51
|
+
return merged;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* This machine's stable id: read from config if present, else generate a UUID
|
|
59
|
+
* and persist it for next time. Never throws.
|
|
60
|
+
*/
|
|
61
|
+
export function ensureMachineId(env = process.env) {
|
|
62
|
+
const existing = readConfig(env).machineId;
|
|
63
|
+
if (typeof existing === "string" && existing) return existing;
|
|
64
|
+
const id = randomUUID();
|
|
65
|
+
const written = writeConfig({ machineId: id }, env);
|
|
66
|
+
return (written && written.machineId) || id;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** This machine's hostname (not persisted — just os.hostname()). */
|
|
70
|
+
export function host() {
|
|
71
|
+
return hostname();
|
|
72
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git-ops telemetry — a durable, queryable record of fold/reap/sync/deploy stage
|
|
3
|
+
* transitions, so "what's slow / what's failing / what keeps needing a human" is
|
|
4
|
+
* answerable without a manual digging session after the fact.
|
|
5
|
+
*
|
|
6
|
+
* A SIBLING to activity-log.mjs, not an extension of it: activity-log is a
|
|
7
|
+
* 300-line RING (correct for its feedback-breadcrumb use case — bounded, most
|
|
8
|
+
* recent wins). Telemetry needs the opposite shape — unbounded, longitudinal
|
|
9
|
+
* history — so it uses a separate, date-partitioned, append-only stream:
|
|
10
|
+
* `~/.tot/telemetry/YYYY-MM.jsonl` (one file per UTC calendar month, partitioned
|
|
11
|
+
* by the entry's own `ts`), never truncated. Retention is "delete whole expired
|
|
12
|
+
* partitions" (see pruneOldPartitions), not the ring's overwrite-oldest-entry.
|
|
13
|
+
*
|
|
14
|
+
* Kept deliberately generic and self-describing: no coupling to
|
|
15
|
+
* workstream_analytics' event-journal shape. Callers own their own event schema
|
|
16
|
+
* (op/stage/reason codes are a separate concern — see the git-ops-telemetry
|
|
17
|
+
* handoff's U2 — not this module's).
|
|
18
|
+
*
|
|
19
|
+
* Append strategy differs from activity-log's temp+rename-the-whole-file (right
|
|
20
|
+
* for a small bounded ring, wrong here — it would mean rewriting a growing
|
|
21
|
+
* multi-KB month file on every single stage transition). Instead this appends
|
|
22
|
+
* with a single O_APPEND write() per entry: POSIX guarantees a single write()
|
|
23
|
+
* below PIPE_BUF is atomic, so concurrent writers (several git-ops scripts
|
|
24
|
+
* running at once, on one machine) can't interleave partial lines.
|
|
25
|
+
*
|
|
26
|
+
* NEVER throws / never blocks the caller — same best-effort contract as
|
|
27
|
+
* activity-log. `TOT_HOME` overrides the home dir (tests).
|
|
28
|
+
*
|
|
29
|
+
* A parallel POSIX-shell implementation lives at `scripts/lib/telemetry.sh` for
|
|
30
|
+
* the bash git-ops scripts this feeds — shelling out to `node` per stage
|
|
31
|
+
* transition would add measurement noise to the durations being measured. The
|
|
32
|
+
* two are separate, from-scratch implementations (no way to share code across
|
|
33
|
+
* languages); `scripts/lib/telemetry-cross-impl.test.mjs` exercises both against
|
|
34
|
+
* the same inputs and asserts schema-identical output as a drift guard. Any
|
|
35
|
+
* change to the on-disk shape here must be mirrored there.
|
|
36
|
+
*/
|
|
37
|
+
import {
|
|
38
|
+
appendFileSync, mkdirSync, chmodSync, readdirSync, rmSync, existsSync, readFileSync,
|
|
39
|
+
} from "node:fs";
|
|
40
|
+
import { homedir } from "node:os";
|
|
41
|
+
import { join } from "node:path";
|
|
42
|
+
import { redactArgs } from "./activity-log.mjs";
|
|
43
|
+
import { ensureMachineId, host } from "./machine-id.mjs";
|
|
44
|
+
|
|
45
|
+
/** Keep telemetry partitions for this many days; whole months older than this are pruned. */
|
|
46
|
+
export const RETENTION_DAYS = 90;
|
|
47
|
+
|
|
48
|
+
/** Directory holding all telemetry partitions for this environment. */
|
|
49
|
+
export function telemetryDir(env = process.env) {
|
|
50
|
+
const home = env.TOT_HOME || homedir();
|
|
51
|
+
return join(home, ".tot", "telemetry");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The `YYYY-MM` partition key for a Date, in UTC. */
|
|
55
|
+
export function monthKey(date) {
|
|
56
|
+
return date.toISOString().slice(0, 7);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Absolute path to the partition file a given Date's entries land in. */
|
|
60
|
+
export function telemetryPath(date, env = process.env) {
|
|
61
|
+
return join(telemetryDir(env), `${monthKey(date)}.jsonl`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function stamped(entry, env) {
|
|
65
|
+
const out = { ...entry };
|
|
66
|
+
if (!out.ts) out.ts = new Date().toISOString();
|
|
67
|
+
if (!out.host) out.host = host();
|
|
68
|
+
if (!out.machineId) out.machineId = ensureMachineId(env);
|
|
69
|
+
if (Array.isArray(out.args)) out.args = redactArgs(out.args);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Delete partitions wholly older than `retentionDays`. Cheap to run on every
|
|
75
|
+
* write: partition files number in the dozens at most (one per month), so a
|
|
76
|
+
* full directory listing per call is fine — no separate prune entrypoint or
|
|
77
|
+
* schedule to forget to run.
|
|
78
|
+
*/
|
|
79
|
+
export function pruneOldPartitions(env = process.env, { retentionDays = RETENTION_DAYS, now = new Date() } = {}) {
|
|
80
|
+
try {
|
|
81
|
+
const dir = telemetryDir(env);
|
|
82
|
+
if (!existsSync(dir)) return;
|
|
83
|
+
const cutoff = monthKey(new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000));
|
|
84
|
+
for (const name of readdirSync(dir)) {
|
|
85
|
+
const match = name.match(/^(\d{4}-\d{2})\.jsonl$/);
|
|
86
|
+
if (match && match[1] < cutoff) {
|
|
87
|
+
rmSync(join(dir, name), { force: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
/* best-effort: never throw */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Append one telemetry entry to its month partition. Stamps `ts`/`host`/
|
|
97
|
+
* `machineId` when the caller omits them (never overrides an explicit value),
|
|
98
|
+
* redacts `args` if present (same secret flags as activity-log), and prunes
|
|
99
|
+
* expired partitions after a successful write. Returns the entry actually
|
|
100
|
+
* written, or null on failure. Never throws.
|
|
101
|
+
*/
|
|
102
|
+
export function recordTelemetry(entry, env = process.env) {
|
|
103
|
+
try {
|
|
104
|
+
const full = stamped(entry, env);
|
|
105
|
+
const dir = telemetryDir(env);
|
|
106
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
107
|
+
const filePath = telemetryPath(new Date(full.ts), env);
|
|
108
|
+
appendFileSync(filePath, `${JSON.stringify(full)}\n`, { mode: 0o600, flag: "a" });
|
|
109
|
+
chmodSync(filePath, 0o600);
|
|
110
|
+
pruneOldPartitions(env);
|
|
111
|
+
return full;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Read parsed entries from one month's partition (default: current UTC month). Never throws. */
|
|
118
|
+
export function readTelemetry(env = process.env, { month = "" } = {}) {
|
|
119
|
+
const key = month || monthKey(new Date());
|
|
120
|
+
const filePath = join(telemetryDir(env), `${key}.jsonl`);
|
|
121
|
+
try {
|
|
122
|
+
return readFileSync(filePath, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
123
|
+
} catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
}
|