perimetercli 0.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/src/serve.js ADDED
@@ -0,0 +1,64 @@
1
+ import http from "node:http";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ const MIME = {
6
+ ".html": "text/html; charset=utf-8",
7
+ ".htm": "text/html; charset=utf-8",
8
+ ".css": "text/css; charset=utf-8",
9
+ ".js": "text/javascript; charset=utf-8",
10
+ ".json": "application/json",
11
+ ".svg": "image/svg+xml",
12
+ ".png": "image/png",
13
+ ".md": "text/markdown; charset=utf-8",
14
+ ".txt": "text/plain; charset=utf-8",
15
+ ".sarif": "application/json; charset=utf-8",
16
+ };
17
+
18
+ export function createStaticServer(dir, { host = "127.0.0.1", port = 0 } = {}) {
19
+ const root = path.resolve(dir);
20
+ const server = http.createServer((req, res) => {
21
+ try {
22
+ let urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
23
+ if (urlPath === "/") urlPath = "/index.html";
24
+ const candidate = path.normalize(path.join(root, urlPath));
25
+ if (!candidate.startsWith(root)) {
26
+ res.writeHead(403);
27
+ res.end("Forbidden");
28
+ return;
29
+ }
30
+ let file = candidate;
31
+ if (fs.existsSync(file) && fs.statSync(file).isDirectory()) {
32
+ file = path.join(file, "index.html");
33
+ }
34
+ if (!fs.existsSync(file)) {
35
+ const index = path.join(root, "index.html");
36
+ if (fs.existsSync(index)) {
37
+ file = index;
38
+ } else {
39
+ res.writeHead(404, { "Content-Type": "text/plain" });
40
+ res.end("Not found");
41
+ return;
42
+ }
43
+ }
44
+ const stat = fs.statSync(file);
45
+ res.writeHead(200, {
46
+ "Content-Type": MIME[path.extname(file).toLowerCase()] || "application/octet-stream",
47
+ "Content-Length": stat.size,
48
+ "Cache-Control": "no-cache",
49
+ });
50
+ fs.createReadStream(file).pipe(res);
51
+ } catch {
52
+ res.writeHead(500);
53
+ res.end("Internal error");
54
+ }
55
+ });
56
+ return { server, root };
57
+ }
58
+
59
+ export function listen(server, { host = "127.0.0.1", port = 0 } = {}) {
60
+ return new Promise((resolve, reject) => {
61
+ server.on("error", reject);
62
+ server.listen(port, host, () => resolve(server.address()));
63
+ });
64
+ }
package/src/server.js ADDED
@@ -0,0 +1,194 @@
1
+ import http from "node:http";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { escapeHtml, isoNow } from "./util.js";
5
+ import { money } from "./tokens.js";
6
+
7
+ function slugify(name) {
8
+ return String(name || "project")
9
+ .toLowerCase()
10
+ .trim()
11
+ .replace(/[^a-z0-9]+/g, "-")
12
+ .replace(/(^-|-$)/g, "")
13
+ .slice(0, 64) || "project";
14
+ }
15
+
16
+ function sevColor(sev) {
17
+ return {
18
+ critical: "#ef4444",
19
+ high: "#f97316",
20
+ medium: "#facc15",
21
+ low: "#3b82f6",
22
+ info: "#94a3b8",
23
+ }[sev] || "#94a3b8";
24
+ }
25
+
26
+ function listProjects(dataDir) {
27
+ if (!fs.existsSync(dataDir)) return [];
28
+ const projects = [];
29
+ for (const entry of fs.readdirSync(dataDir, { withFileTypes: true })) {
30
+ if (!entry.isDirectory()) continue;
31
+ const latest = path.join(dataDir, entry.name, "latest.json");
32
+ if (!fs.existsSync(latest)) continue;
33
+ let audit;
34
+ try {
35
+ audit = JSON.parse(fs.readFileSync(latest, "utf8"));
36
+ } catch {
37
+ continue;
38
+ }
39
+ projects.push({ slug: entry.name, latest: audit });
40
+ }
41
+ projects.sort(
42
+ (a, b) => new Date(b.latest.generatedAt || 0) - new Date(a.latest.generatedAt || 0)
43
+ );
44
+ return projects;
45
+ }
46
+
47
+ function renderDashboard(projects) {
48
+ const cards = projects
49
+ .map((p) => {
50
+ const a = p.latest;
51
+ const verdict = a.effectiveVerdict || a.verdict || "info";
52
+ const counts = a.counts || {};
53
+ const findings = (a.findings || []).length;
54
+ const tokens = a.costs?.totalTokens ?? 0;
55
+ const cost = a.costs?.perLoadCost ?? 0;
56
+ return `<a class="card" href="/projects/${escapeHtml(p.slug)}">
57
+ <div class="row"><span class="name">${escapeHtml(a.name || p.slug)}</span><span class="verdict" style="background:${sevColor(verdict)}">${escapeHtml(verdict.toUpperCase())}</span></div>
58
+ <div class="meta">${findings} findings · ${counts.critical ?? 0} critical · ${counts.high ?? 0} high</div>
59
+ <div class="meta">${tokens.toLocaleString()} tokens/load · ${money(cost)}/load</div>
60
+ <div class="meta dim">${escapeHtml((a.generatedAt || "").slice(0, 19).replace("T", " "))}</div>
61
+ </a>`;
62
+ })
63
+ .join("\n");
64
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
65
+ <title>Perimeter — fleet audit</title><style>
66
+ :root{--bg:#0b0f17;--bg2:#111827;--border:#1f2937;--text:#e5e7eb;--muted:#94a3b8}
67
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:ui-sans-serif,system-ui,sans-serif;line-height:1.6}
68
+ .wrap{max-width:1000px;margin:0 auto;padding:32px 24px}h1{letter-spacing:-.02em}
69
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-top:20px}
70
+ .card{background:var(--bg2);border:1px solid var(--border);border-radius:14px;padding:18px;text-decoration:none;color:var(--text)}
71
+ .card:hover{border-color:#6366f1}.row{display:flex;justify-content:space-between;align-items:center;gap:12px}
72
+ .name{font-weight:700;font-size:16px}.verdict{color:#fff;font-weight:700;font-size:12px;padding:3px 10px;border-radius:999px}
73
+ .meta{color:var(--muted);font-size:13px;margin-top:8px}.dim{opacity:.7}.muted{color:var(--muted)}
74
+ .empty{color:var(--muted);padding:20px 0}footer{margin-top:32px;color:var(--muted);font-size:13px;text-align:center}
75
+ </style></head><body><div class="wrap"><h1>Perimeter · fleet audit</h1>
76
+ <p class="muted">Continuous audit history. Push a report with <code>perimeter push &lt;url&gt; --project &lt;name&gt;</code>.</p>
77
+ ${cards ? `<div class="grid">${cards}</div>` : `<p class="empty">No audits yet.</p>`}
78
+ <footer>Perimeter server</footer></div></body></html>`;
79
+ }
80
+
81
+ function renderProject(slug, audits) {
82
+ const latest = audits[0];
83
+ const a = latest;
84
+ const verdict = a.effectiveVerdict || a.verdict || "info";
85
+ const findings = (a.findings || []).slice(0, 60);
86
+ const findingsHtml = findings.length
87
+ ? findings.map((f) => `<div class="f">
88
+ <span class="sev" style="color:${sevColor(f.severity)}">${escapeHtml((f.severity || "").toUpperCase())}</span>
89
+ <code>${escapeHtml(f.id)}</code> <strong>${escapeHtml(f.title)}</strong>
90
+ <div class="dim">${escapeHtml(f.server || "")} — ${escapeHtml(f.detail || "")}</div>
91
+ </div>`).join("\n")
92
+ : `<p class="muted">No findings.</p>`;
93
+ const history = audits.map((u) => {
94
+ const v = u.effectiveVerdict || u.verdict || "info";
95
+ return `<li><span class="verdict" style="background:${sevColor(v)}">${escapeHtml(v.toUpperCase())}</span> ${escapeHtml(
96
+ (u.generatedAt || "").slice(0, 19).replace("T", " ")
97
+ )} · ${(u.findings || []).length} findings</li>`;
98
+ }).join("\n");
99
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
100
+ <title>${escapeHtml(a.name || slug)} — Perimeter</title><style>
101
+ :root{--bg:#0b0f17;--bg2:#111827;--border:#1f2937;--text:#e5e7eb;--muted:#94a3b8}
102
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:ui-sans-serif,system-ui,sans-serif;line-height:1.6}
103
+ .wrap{max-width:960px;margin:0 auto;padding:32px 24px}h1{letter-spacing:-.02em}
104
+ .f{background:var(--bg2);border:1px solid var(--border);border-radius:12px;padding:12px 16px;margin:10px 0}
105
+ .sev{font-weight:800;font-size:12px;margin-right:8px}.dim{color:var(--muted);font-size:13px;margin-top:4px}
106
+ code{background:#0f172a;padding:1px 6px;border-radius:5px}ul{list-style:none;padding:0}
107
+ li{display:flex;align-items:center;gap:10px;padding:6px 0}.verdict{color:#fff;font-weight:700;font-size:11px;padding:2px 9px;border-radius:999px}
108
+ .muted{color:var(--muted)}a{color:#fca5a5}
109
+ </style></head><body><div class="wrap"><a href="/">← Fleet</a>
110
+ <h1>${escapeHtml(a.name || slug)}</h1>
111
+ <p class="muted">${audits.length} audit(s) · latest ${escapeHtml(verdict.toUpperCase())}</p>
112
+ <h2>History</h2><ul>${history || "<li class=\"muted\">none</li>"}</ul>
113
+ <h2>Latest findings</h2>${findingsHtml}
114
+ </div></body></html>`;
115
+ }
116
+
117
+ function readAudits(dataDir, slug) {
118
+ const dir = path.join(dataDir, slug);
119
+ if (!fs.existsSync(dir)) return [];
120
+ return fs
121
+ .readdirSync(dir)
122
+ .filter((f) => f.endsWith(".json") && f !== "latest.json")
123
+ .map((f) => {
124
+ try {
125
+ return JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
126
+ } catch {
127
+ return null;
128
+ }
129
+ })
130
+ .filter(Boolean)
131
+ .sort((a, b) => new Date(b.generatedAt || 0) - new Date(a.generatedAt || 0));
132
+ }
133
+
134
+ export function createServer({ dataDir = ".perimeter/data", host = "127.0.0.1", port = 0 } = {}) {
135
+ fs.mkdirSync(dataDir, { recursive: true });
136
+ const server = http.createServer((req, res) => {
137
+ const url = new URL(req.url || "/", `http://${host}`);
138
+ const pathname = url.pathname;
139
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
140
+ if (req.method === "POST" && pathname === "/audit") {
141
+ let body = "";
142
+ req.on("data", (c) => (body += c));
143
+ req.on("end", () => {
144
+ try {
145
+ const audit = JSON.parse(body);
146
+ const project = url.searchParams.get("project") || audit.name || "project";
147
+ const slug = slugify(project);
148
+ const dir = path.join(dataDir, slug);
149
+ fs.mkdirSync(dir, { recursive: true });
150
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
151
+ fs.writeFileSync(path.join(dir, `${ts}.json`), JSON.stringify(audit, null, 2));
152
+ fs.writeFileSync(path.join(dir, "latest.json"), JSON.stringify(audit, null, 2));
153
+ res.writeHead(200);
154
+ res.end(JSON.stringify({ ok: true, project: slug }));
155
+ } catch (err) {
156
+ res.writeHead(500);
157
+ res.end(JSON.stringify({ ok: false, error: String(err.message) }));
158
+ }
159
+ });
160
+ return;
161
+ }
162
+ if (pathname === "/" || pathname === "") {
163
+ res.end(renderDashboard(listProjects(dataDir)));
164
+ return;
165
+ }
166
+ const projectMatch = pathname.match(/^\/projects\/([a-z0-9-]+)\/?$/);
167
+ if (projectMatch) {
168
+ const slug = projectMatch[1];
169
+ const audits = readAudits(dataDir, slug);
170
+ if (audits.length === 0) {
171
+ res.writeHead(404);
172
+ res.end("Not found");
173
+ } else {
174
+ res.end(renderProject(slug, audits));
175
+ }
176
+ return;
177
+ }
178
+ if (pathname === "/health") {
179
+ res.setHeader("Content-Type", "application/json");
180
+ res.end(JSON.stringify({ ok: true }));
181
+ return;
182
+ }
183
+ res.writeHead(404);
184
+ res.end("Not found");
185
+ });
186
+ return { server, dataDir };
187
+ }
188
+
189
+ export function listen(server, { host = "127.0.0.1", port = 0 } = {}) {
190
+ return new Promise((resolve, reject) => {
191
+ server.on("error", reject);
192
+ server.listen(port, host, () => resolve(server.address()));
193
+ });
194
+ }
@@ -0,0 +1,161 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import { readIfExists, walkFiles } from "./util.js";
5
+ import { estimateCost, tokensForText, resolveModel } from "./tokens.js";
6
+
7
+ const HOME = os.homedir();
8
+
9
+ // Claude Code tool name → capability + severity when actually used.
10
+ const TOOL_PROFILES = {
11
+ Bash: { capability: "shell", severity: "critical" },
12
+ Write: { capability: "filesystem-write", severity: "medium" },
13
+ Edit: { capability: "filesystem-write", severity: "medium" },
14
+ MultiEdit: { capability: "filesystem-write", severity: "medium" },
15
+ NotebookEdit: { capability: "code-exec", severity: "high" },
16
+ WebFetch: { capability: "network-in", severity: "low" },
17
+ WebSearch: { capability: "network-in", severity: "low" },
18
+ Task: { capability: "subagent", severity: "info" },
19
+ TodoWrite: { capability: "todo", severity: "info" },
20
+ Grep: { capability: "search", severity: "info" },
21
+ };
22
+
23
+ export function findSessionDirs(cwd, opts = {}) {
24
+ const dirs = [
25
+ path.join(cwd, "sessions"),
26
+ path.join(cwd, ".perimeter", "sessions"),
27
+ path.join(cwd, ".claude", "projects"),
28
+ ];
29
+ if (opts.home !== false && HOME !== cwd) {
30
+ dirs.push(path.join(HOME, ".claude", "projects"));
31
+ dirs.push(path.join(HOME, ".codex", "sessions"));
32
+ }
33
+ if (opts.sessions) dirs.push(opts.sessions);
34
+ return dirs.filter((d) => d && fs.existsSync(d));
35
+ }
36
+
37
+ export function findSessions(cwd, opts = {}) {
38
+ const files = [];
39
+ for (const dir of findSessionDirs(cwd, opts)) {
40
+ for (const f of walkFiles(dir, [".git", "node_modules"])) {
41
+ if (f.endsWith(".jsonl")) files.push(f);
42
+ }
43
+ }
44
+ return files;
45
+ }
46
+
47
+ /**
48
+ * Parse a Claude Code (or Codex-compatible) JSONL session.
49
+ * Returns { file, tools, inputTokens, outputTokens, messages }.
50
+ */
51
+ export function parseSessionFile(file) {
52
+ const raw = readIfExists(file) || "";
53
+ const lines = raw.split(/\r?\n/).filter(Boolean);
54
+ const tools = {};
55
+ let inputTokens = 0;
56
+ let outputTokens = 0;
57
+ let messages = 0;
58
+ let runs = 0;
59
+
60
+ for (const line of lines) {
61
+ let obj;
62
+ try {
63
+ obj = JSON.parse(line);
64
+ } catch {
65
+ continue;
66
+ }
67
+ if (obj && obj.type === "assistant") {
68
+ messages++;
69
+ const usage = obj.message && obj.message.usage;
70
+ if (usage) {
71
+ inputTokens += Number(usage.input_tokens) || 0;
72
+ outputTokens += Number(usage.output_tokens) || 0;
73
+ }
74
+ const content = (obj.message && obj.message.content) || [];
75
+ if (Array.isArray(content)) {
76
+ for (const block of content) {
77
+ if (block && block.type === "tool_use" && block.name) {
78
+ tools[block.name] = (tools[block.name] || 0) + 1;
79
+ }
80
+ }
81
+ }
82
+ } else if (obj && obj.type === "user") {
83
+ messages++;
84
+ } else if (obj && obj.message && obj.message.role) {
85
+ // Generic/Codex-ish line
86
+ messages++;
87
+ const content = obj.message.content || obj.message.role === "assistant"
88
+ ? obj.message.content
89
+ : null;
90
+ if (Array.isArray(content)) {
91
+ for (const block of content) {
92
+ if (block && block.name) {
93
+ tools[block.name] = (tools[block.name] || 0) + 1;
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+ runs = messages ? 1 : 0;
100
+ return { file, tools, inputTokens, outputTokens, messages, runs, tokenized: false };
101
+ }
102
+
103
+ function tokenizeTool(name, desc) {
104
+ return tokensForText(`${name} ${desc}`);
105
+ }
106
+
107
+ /**
108
+ * Build a runtime observability report from session logs.
109
+ */
110
+ export function buildSessionReport(cwd, opts = {}) {
111
+ const files = findSessions(cwd, opts);
112
+ const sessions = [];
113
+ // Track a stable "tool risk" across all runs.
114
+ const toolTotals = {};
115
+ let inputTokens = 0;
116
+ let outputTokens = 0;
117
+ let messages = 0;
118
+
119
+ for (const file of files) {
120
+ const parsed = parseSessionFile(file);
121
+ if (parsed.messages === 0) continue;
122
+ inputTokens += parsed.inputTokens;
123
+ outputTokens += parsed.outputTokens;
124
+ messages += parsed.messages;
125
+ for (const [name, count] of Object.entries(parsed.tools)) {
126
+ toolTotals[name] = (toolTotals[name] || 0) + count;
127
+ }
128
+ sessions.push(parsed);
129
+ }
130
+
131
+ const tools = Object.entries(toolTotals)
132
+ .map(([name, count]) => {
133
+ const profile = TOOL_PROFILES[name] || { capability: "unrecognised", severity: "info" };
134
+ return {
135
+ name,
136
+ count,
137
+ capability: profile.capability,
138
+ severity: profile.severity,
139
+ };
140
+ })
141
+ .sort((a, b) => b.count - a.count);
142
+
143
+ // If real usage totals are unavailable, fall back to an estimate from the
144
+ // message text so the report is still actionable.
145
+ const totalTokens = inputTokens + outputTokens;
146
+ const cost = estimateCost(totalTokens || messages * 600, opts.model || "claude-sonnet");
147
+
148
+ return {
149
+ generatedAt: new Date().toISOString(),
150
+ sessions: sessions.length,
151
+ files: files.length,
152
+ messages,
153
+ inputTokens,
154
+ outputTokens,
155
+ totalTokens,
156
+ usageExact: inputTokens > 0 || outputTokens > 0,
157
+ tools,
158
+ riskyTools: tools.filter((t) => ["critical", "high"].includes(t.severity)),
159
+ cost,
160
+ };
161
+ }
package/src/tokens.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Dependency-free token and cost estimation. Token counts are heuristic
3
+ * (≈ chars/4 for English + structure overhead) — deliberately conservative and
4
+ * deterministic, so reports are reproducible offline.
5
+ */
6
+
7
+ export const MODEL_PRICES = {
8
+ "claude-opus": { in: 15, out: 75 },
9
+ "claude-sonnet": { in: 3, out: 15 },
10
+ "claude-haiku": { in: 1, out: 5 },
11
+ "gpt-5": { in: 1.25, out: 10 },
12
+ "gpt-4o": { in: 2.5, out: 10 },
13
+ "gpt-4.1": { in: 2, out: 8 },
14
+ "o3": { in: 2, out: 8 },
15
+ "gemini-2.5-pro": { in: 1.25, out: 10 },
16
+ "gemini-2.5-flash": { in: 0.3, out: 2.5 },
17
+ "deepseek-v3": { in: 0.27, out: 1.1 },
18
+ "grok-4": { in: 3, out: 15 },
19
+ "llama-4": { in: 0.5, out: 0.5 },
20
+ };
21
+
22
+ const MODEL_ALIASES = {
23
+ "claude-4-opus": "claude-opus",
24
+ "claude-4-sonnet": "claude-sonnet",
25
+ "claude-3-7-sonnet": "claude-sonnet",
26
+ "claude-3-5-sonnet": "claude-sonnet",
27
+ "claude-opus-4": "claude-opus",
28
+ "claude-sonnet-4": "claude-sonnet",
29
+ "gpt-5": "gpt-5",
30
+ "gpt-4o": "gpt-4o",
31
+ "gpt-4.1": "gpt-4.1",
32
+ "o3": "o3",
33
+ "grok-4": "grok-4",
34
+ "grok-3": "grok-4",
35
+ "deepseek": "deepseek-v3",
36
+ };
37
+
38
+ export function resolveModel(model) {
39
+ const key = String(model || "").toLowerCase().trim();
40
+ const canonical = MODEL_ALIASES[key] || key;
41
+ const price = MODEL_PRICES[canonical];
42
+ return {
43
+ name: canonical || "generic",
44
+ in: price?.in ?? 5,
45
+ out: price?.out ?? 15,
46
+ estimated: !price,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Estimate tokens for a single schema string. We add a small structural
52
+ * overhead for JSON punctuation and whitespace.
53
+ */
54
+ export function tokensForText(text) {
55
+ const clean = String(text || "");
56
+ const asciiChars = clean.length;
57
+ const words = clean.split(/\s+/).filter(Boolean).length;
58
+ // Blend a char/4 estimate with a word-based estimate.
59
+ const byChar = asciiChars / 4;
60
+ const byWord = words * 1.3 + 5;
61
+ return Math.max(1, Math.ceil((byChar + byWord) / 2));
62
+ }
63
+
64
+ /** Estimate tokens for a list of tools (name + description + input schema). */
65
+ export function tokensForTools(tools, overhead = 400) {
66
+ let total = overhead;
67
+ for (const tool of tools || []) {
68
+ total += tokensForText(tool?.name || "");
69
+ total += tokensForText(tool?.description || "");
70
+ total += tokensForText(JSON.stringify(tool?.inputSchema || {}));
71
+ }
72
+ return total;
73
+ }
74
+
75
+ /** Costs, in dollars, to load a server (input tokens) and per assumed turn. */
76
+ export function estimateCost(inputTokens, model = "claude-sonnet", opts = {}) {
77
+ const resolved = resolveModel(model);
78
+ const outputFactor = opts.outputFactor ?? 0.5; // approximate output/turn
79
+ const perLoadInput = inputTokens / 1_000_000 * resolved.in;
80
+ const perLoadOutput = inputTokens / 1_000_000 * resolved.out * outputFactor;
81
+ return {
82
+ model: resolved.name,
83
+ inputRate: resolved.in,
84
+ outputRate: resolved.out,
85
+ estimated: resolved.estimated,
86
+ perLoad: perLoadInput + perLoadOutput,
87
+ perLoadInput,
88
+ perLoadOutput,
89
+ };
90
+ }
91
+
92
+ /** Format a dollar amount in a readable way. */
93
+ export function money(value) {
94
+ if (value === null || value === undefined || Number.isNaN(value)) return "—";
95
+ if (value === 0) return "$0.00";
96
+ if (value < 0.01) return "<$0.01";
97
+ if (value < 1) return `$${value.toFixed(2)}`;
98
+ return `$${value.toFixed(2)}`;
99
+ }
package/src/util.js ADDED
@@ -0,0 +1,137 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+
5
+ export const colors = {
6
+ reset: "\x1b[0m",
7
+ bold: "\x1b[1m",
8
+ dim: "\x1b[2m",
9
+ gray: "\x1b[90m",
10
+ cyan: "\x1b[36m",
11
+ green: "\x1b[32m",
12
+ yellow: "\x1b[33m",
13
+ red: "\x1b[31m",
14
+ magenta: "\x1b[35m",
15
+ blue: "\x1b[34m",
16
+ };
17
+
18
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
19
+ const paint = (text, code) =>
20
+ useColor ? `${colors[code] ?? ""}${text}${colors.reset}` : text;
21
+
22
+ export const dim = (s) => paint(s, "dim");
23
+ export const bold = (s) => paint(s, "bold");
24
+ export const green = (s) => paint(s, "green");
25
+ export const red = (s) => paint(s, "red");
26
+ export const cyan = (s) => paint(s, "cyan");
27
+ export const yellow = (s) => paint(s, "yellow");
28
+ export const magenta = (s) => paint(s, "magenta");
29
+ export const gray = (s) => paint(s, "gray");
30
+
31
+ export function log(msg = "") {
32
+ process.stdout.write(`${msg}\n`);
33
+ }
34
+ export function info(msg) {
35
+ process.stdout.write(`${cyan("•")} ${msg}\n`);
36
+ }
37
+ export function success(msg) {
38
+ process.stdout.write(`${green("✓")} ${msg}\n`);
39
+ }
40
+ export function warn(msg) {
41
+ process.stderr.write(`${yellow("warn")}: ${msg}\n`);
42
+ }
43
+ export function error(msg) {
44
+ process.stderr.write(`${red("error")}: ${msg}\n`);
45
+ }
46
+
47
+ export function readIfExists(file) {
48
+ try {
49
+ return fs.readFileSync(file, "utf8");
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+ export function readJson(file) {
55
+ const raw = readIfExists(file);
56
+ if (!raw) return null;
57
+ try {
58
+ return JSON.parse(raw);
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+ export function writeJson(file, value) {
64
+ fs.mkdirSync(path.dirname(file), { recursive: true });
65
+ fs.writeFileSync(file, JSON.stringify(value, null, 2));
66
+ }
67
+ export function writeFile(file, content) {
68
+ fs.mkdirSync(path.dirname(file), { recursive: true });
69
+ fs.writeFileSync(file, content);
70
+ }
71
+
72
+ export function walkFiles(root, exclude = []) {
73
+ const excludeSet = new Set([
74
+ ".git",
75
+ "node_modules",
76
+ ".hg",
77
+ ".svn",
78
+ "dist",
79
+ "build",
80
+ "coverage",
81
+ "fathom-dist",
82
+ ".next",
83
+ ".nuxt",
84
+ "venv",
85
+ ".venv",
86
+ "__pycache__",
87
+ ".tox",
88
+ ...exclude,
89
+ ]);
90
+ const results = [];
91
+ function walk(dir) {
92
+ let entries;
93
+ try {
94
+ entries = fs.readdirSync(dir, { withFileTypes: true });
95
+ } catch {
96
+ return;
97
+ }
98
+ for (const entry of entries) {
99
+ if (entry.isSymbolicLink()) continue;
100
+ if (entry.isDirectory()) {
101
+ if (excludeSet.has(entry.name)) continue;
102
+ walk(path.join(dir, entry.name));
103
+ } else {
104
+ results.push(path.join(dir, entry.name));
105
+ }
106
+ }
107
+ }
108
+ walk(root);
109
+ return results;
110
+ }
111
+
112
+ export function sha256(text) {
113
+ return crypto.createHash("sha256").update(text).digest("hex");
114
+ }
115
+
116
+ export function jsonSize(obj) {
117
+ return JSON.stringify(obj)?.length ?? 0;
118
+ }
119
+
120
+ export function escapeHtml(str) {
121
+ return String(str)
122
+ .replace(/&/g, "&amp;")
123
+ .replace(/</g, "&lt;")
124
+ .replace(/>/g, "&gt;")
125
+ .replace(/"/g, "&quot;")
126
+ .replace(/'/g, "&#39;");
127
+ }
128
+
129
+ export function fileSize(bytes) {
130
+ if (bytes < 1024) return `${bytes} B`;
131
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
132
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
133
+ }
134
+
135
+ export function isoNow() {
136
+ return new Date().toISOString();
137
+ }
package/src/version.js ADDED
@@ -0,0 +1 @@
1
+ export const VERSION = "0.1.0";