nilometer 0.0.0-stage → 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/CHANGELOG.md +63 -0
- package/LICENSE +21 -0
- package/README.md +462 -2
- package/core/pricing/prices.json +348 -0
- package/core/schema/001_ingestion.sql +57 -0
- package/core/schema/002_line_count.sql +7 -0
- package/core/schema/003_derived.sql +70 -0
- package/core/schema/004_requests_dedup.sql +50 -0
- package/core/schema/005_repositories.sql +25 -0
- package/core/schema/006_status_readings.sql +33 -0
- package/core/schema/007_pricing.sql +95 -0
- package/core/schema/008_interruptions.sql +263 -0
- package/core/schema/009_headroom.sql +84 -0
- package/core/schema/010_attribution.sql +106 -0
- package/core/schema/011_projections.sql +83 -0
- package/core/schema/012_unsplit_cache_writes.sql +49 -0
- package/core/schema/013_repository_revalidation.sql +104 -0
- package/core/schema/014_observed_readings.sql +204 -0
- package/core/schema/015_response_observations.sql +127 -0
- package/core/schema/016_windows_repository_paths.sql +41 -0
- package/core/schema/017_unattributed_usage_once.sql +47 -0
- package/core/schema/018_request_time_index.sql +21 -0
- package/core/schema/019_dedup_earliest_of_equals.sql +61 -0
- package/core/schema/020_limit_quota_fields.sql +131 -0
- package/core/schema/021_plan_periods.sql +121 -0
- package/core/schema/022_attachment_walk.sql +223 -0
- package/core/schema/023_auto_continuations.sql +129 -0
- package/core/schema/024_counted_limit_groups.sql +29 -0
- package/core/schema/025_model_fallback.sql +77 -0
- package/core/schema/026_changed_readings.sql +106 -0
- package/core/schema/027_weekly_crossings.sql +32 -0
- package/core/schema/028_stale_readings.sql +196 -0
- package/core/schema/029_lockout_sessions.sql +68 -0
- package/core/schema/030_repository_aliases.sql +81 -0
- package/core/schema/031_cte_cursor_budget.sql +153 -0
- package/dist/cli/main.cli.js +39 -0
- package/dist/cli/program.js +801 -0
- package/dist/core/db/database.js +182 -0
- package/dist/core/db/fingerprint.js +71 -0
- package/dist/core/ingest/attribution.js +131 -0
- package/dist/core/ingest/classify.js +382 -0
- package/dist/core/ingest/command.js +95 -0
- package/dist/core/ingest/derive.js +212 -0
- package/dist/core/ingest/discover.js +126 -0
- package/dist/core/ingest/ingest.js +160 -0
- package/dist/core/ingest/reader.js +89 -0
- package/dist/core/ingest/reset-time.js +97 -0
- package/dist/core/ingest/results.js +201 -0
- package/dist/core/ingest/spool.js +165 -0
- package/dist/core/ingest/store.js +99 -0
- package/dist/core/install/install.js +185 -0
- package/dist/core/install/locations.js +89 -0
- package/dist/core/install/private-files.js +259 -0
- package/dist/core/install/record.js +103 -0
- package/dist/core/plans/plan-prices.js +128 -0
- package/dist/core/plans/repository-aliases.js +136 -0
- package/dist/core/pricing/prices.js +147 -0
- package/dist/core/settings/settings-file.js +215 -0
- package/dist/core/settings/statusline.js +240 -0
- package/dist/core/util/commands.js +91 -0
- package/dist/core/util/node-version.js +58 -0
- package/dist/core/util/open-page.js +69 -0
- package/dist/core/util/paths.js +36 -0
- package/dist/core/verify/compare.js +77 -0
- package/dist/core/verify/verify.js +220 -0
- package/dist/viewer/explain.js +574 -0
- package/dist/viewer/format.js +150 -0
- package/dist/viewer/html-charts.js +1292 -0
- package/dist/viewer/html-contract.js +2 -0
- package/dist/viewer/html-data.js +718 -0
- package/dist/viewer/html.js +1654 -0
- package/dist/viewer/queries.js +128 -0
- package/dist/viewer/render.js +602 -0
- package/dist/viewer/report.js +120 -0
- package/dist/viewer/save.js +110 -0
- package/docs/requirements.md +113 -0
- package/hooks/statusline.sh +104 -0
- package/package.json +61 -4
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Deciding how `init` and `uninstall` change the `statusLine` entry in settings.
|
|
3
|
+
*
|
|
4
|
+
* Implements README § How it works ("Chaining, not clobbering") and the `statusline-collector`
|
|
5
|
+
* skill. Everything here is pure: functions take a settings object and return a new one plus a
|
|
6
|
+
* description of what changed. Reading and writing files is `settings-file.ts`'s job, so every
|
|
7
|
+
* decision can be tested without touching a disk.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { isAbsolute } from "node:path";
|
|
11
|
+
import { readSettings } from "./settings-file.js";
|
|
12
|
+
/**
|
|
13
|
+
* Comment appended to the hook command. It identifies our entry even if the repository moves,
|
|
14
|
+
* so `init` never wraps its own hook (docs/development.md P1.2, "already ours").
|
|
15
|
+
*/
|
|
16
|
+
export const HOOK_MARKER = "# nilometer-hook";
|
|
17
|
+
/**
|
|
18
|
+
* Quotes a string for safe use as one word in a POSIX shell command.
|
|
19
|
+
* @param value - Any string, including spaces, quotes, or `$`.
|
|
20
|
+
* @returns The value in single quotes, with embedded single quotes escaped as `'\''`.
|
|
21
|
+
* @example
|
|
22
|
+
* shellQuote("/a b/it's"); // "'/a b/it'\\''s'"
|
|
23
|
+
*/
|
|
24
|
+
export function shellQuote(value) {
|
|
25
|
+
// Inside single quotes nothing is special except the closing quote itself.
|
|
26
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Reads the data directory out of a command {@link buildHookCommand} produced.
|
|
30
|
+
*
|
|
31
|
+
* `uninstall` needs the install record, which lives in the data directory, to put the user's own
|
|
32
|
+
* status line back. When it's run without the `--data-dir` the install used, the only other place
|
|
33
|
+
* that path is written down is the command being removed, so read it from there (D-050). Without
|
|
34
|
+
* this, an uninstall that can't find the record deletes the `statusLine` entry instead of
|
|
35
|
+
* restoring it.
|
|
36
|
+
* @param command - A `statusLine.command` value.
|
|
37
|
+
* @returns The data directory, or `null` if the command isn't one of ours or can't be parsed.
|
|
38
|
+
* @example
|
|
39
|
+
* dataDirFromHookCommand("/bin/sh '/repo/hooks/statusline.sh' '/data' # nilometer-hook"); // "/data"
|
|
40
|
+
*/
|
|
41
|
+
export function dataDirFromHookCommand(command) {
|
|
42
|
+
if (!command.includes(HOOK_MARKER)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
// Two single-quoted words, the hook path then the data directory, as buildHookCommand writes
|
|
46
|
+
// them: `''` can't appear inside one, because shellQuote escapes an embedded quote as `'\''`.
|
|
47
|
+
const match = /^\/bin\/sh '((?:[^']|'\\'')*)' '((?:[^']|'\\'')*)' /.exec(command);
|
|
48
|
+
const quoted = match?.[2];
|
|
49
|
+
if (quoted === undefined) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return quoted.replaceAll(`'\\''`, "'");
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Reads the hook's own path out of a command {@link buildHookCommand} produced (D-081).
|
|
56
|
+
* @param command - A `statusLine.command` value.
|
|
57
|
+
* @returns The hook script's path, or `null` if the command isn't one of ours or can't be parsed.
|
|
58
|
+
* @example
|
|
59
|
+
* hookPathFromHookCommand("/bin/sh '/repo/hooks/statusline.sh' '/data' # nilometer-hook"); // "/repo/hooks/statusline.sh"
|
|
60
|
+
*/
|
|
61
|
+
export function hookPathFromHookCommand(command) {
|
|
62
|
+
if (!command.includes(HOOK_MARKER)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
// The same two quoted words dataDirFromHookCommand reads; the first is the hook.
|
|
66
|
+
const match = /^\/bin\/sh '((?:[^']|'\\'')*)' '((?:[^']|'\\'')*)' /.exec(command);
|
|
67
|
+
const quoted = match?.[1];
|
|
68
|
+
if (quoted === undefined) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return quoted.replaceAll(`'\\''`, "'");
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Checks whether the status line in settings runs this tool's hook from a file that exists (D-081).
|
|
75
|
+
*
|
|
76
|
+
* Read only: the settings file is never written here. A machine move or a deleted checkout leaves
|
|
77
|
+
* `statusLine.command` naming a hook that isn't there; Claude Code then runs nothing, and the
|
|
78
|
+
* hook's own error log can't record a hook that never starts.
|
|
79
|
+
* @param settingsPath - The settings file, located as `init` locates it.
|
|
80
|
+
* @param exists - File existence check, injectable for tests.
|
|
81
|
+
* @returns `missing` with the hook's path when the command is ours and its file is gone;
|
|
82
|
+
* `present`, `not_ours`, or `no_settings` otherwise. An unreadable or invalid settings file is
|
|
83
|
+
* `no_settings`: there is no command to check.
|
|
84
|
+
*/
|
|
85
|
+
export function checkHook(settingsPath, exists = existsSync) {
|
|
86
|
+
let read;
|
|
87
|
+
try {
|
|
88
|
+
read = readSettings(settingsPath);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { kind: "no_settings" };
|
|
92
|
+
}
|
|
93
|
+
if (read.kind !== "ok") {
|
|
94
|
+
return { kind: "no_settings" };
|
|
95
|
+
}
|
|
96
|
+
const value = read.settings["statusLine"];
|
|
97
|
+
if (!isCommandEntry(value)) {
|
|
98
|
+
return { kind: "not_ours" };
|
|
99
|
+
}
|
|
100
|
+
const hookPath = hookPathFromHookCommand(value.command);
|
|
101
|
+
if (hookPath === null) {
|
|
102
|
+
return { kind: "not_ours" };
|
|
103
|
+
}
|
|
104
|
+
return exists(hookPath) ? { kind: "present" } : { kind: "missing", hookPath };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Builds the exact `statusLine.command` string that runs our hook.
|
|
108
|
+
* @param hookPath - Absolute path to `hooks/statusline.sh`.
|
|
109
|
+
* @throws {Error} If either path is relative: the hook would resolve it against whatever directory
|
|
110
|
+
* Claude Code runs in, which the user doesn't control (D-050).
|
|
111
|
+
* @param dataDir - Absolute path to the tool's data directory.
|
|
112
|
+
* @returns A shell command running the hook under `/bin/sh`, ending in {@link HOOK_MARKER}.
|
|
113
|
+
* @example
|
|
114
|
+
* buildHookCommand("/opt/aua/hooks/statusline.sh", "/home/example/.local/share/nilometer");
|
|
115
|
+
* // "/bin/sh '/opt/aua/hooks/statusline.sh' '/home/example/.local/share/nilometer' # nilometer-hook"
|
|
116
|
+
*/
|
|
117
|
+
export function buildHookCommand(hookPath, dataDir) {
|
|
118
|
+
for (const path of [hookPath, dataDir]) {
|
|
119
|
+
if (!isAbsolute(path)) {
|
|
120
|
+
throw new Error(`the status line command needs absolute paths; got ${path}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Running through /bin/sh explicitly means the script needn't be executable after a git checkout.
|
|
124
|
+
return `/bin/sh ${shellQuote(hookPath)} ${shellQuote(dataDir)} ${HOOK_MARKER}`;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Reports whether a value is a usable `statusLine` entry of type `command`.
|
|
128
|
+
* @param value - The raw `statusLine` value from settings.
|
|
129
|
+
* @returns `true` if it is an object with `type: "command"` and a string `command`.
|
|
130
|
+
*/
|
|
131
|
+
export function isCommandEntry(value) {
|
|
132
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
const record = value;
|
|
136
|
+
return record["type"] === "command" && typeof record["command"] === "string";
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Classifies the current `statusLine` entry.
|
|
140
|
+
* @param settings - Parsed settings; `null` when the settings file doesn't exist.
|
|
141
|
+
* @param hookCommand - The command {@link buildHookCommand} produces for this installation.
|
|
142
|
+
* @returns The entry's state: absent, someone else's, ours (current or outdated), or unsupported.
|
|
143
|
+
*/
|
|
144
|
+
export function classifyStatusLine(settings, hookCommand) {
|
|
145
|
+
// `in` rather than a truthiness check: an explicit `"statusLine": null` isn't "absent".
|
|
146
|
+
if (settings === null || !("statusLine" in settings)) {
|
|
147
|
+
return { kind: "absent" };
|
|
148
|
+
}
|
|
149
|
+
const value = settings["statusLine"];
|
|
150
|
+
// Anything that isn't a command entry is left alone: wrapping it could change what the user sees.
|
|
151
|
+
if (!isCommandEntry(value)) {
|
|
152
|
+
return { kind: "unsupported", value };
|
|
153
|
+
}
|
|
154
|
+
if (!value.command.includes(HOOK_MARKER)) {
|
|
155
|
+
return { kind: "foreign", entry: value };
|
|
156
|
+
}
|
|
157
|
+
// Ours, but pointing somewhere else (e.g. the repository moved): update the command in place.
|
|
158
|
+
return value.command === hookCommand
|
|
159
|
+
? { kind: "ours", entry: value }
|
|
160
|
+
: { kind: "ours-outdated", entry: value };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Returns a copy of settings with `statusLine` replaced, keeping key order.
|
|
164
|
+
* @param settings - Original settings.
|
|
165
|
+
* @param entry - New `statusLine` value, or `undefined` to remove the key.
|
|
166
|
+
* @returns A new object. An existing key stays in place; a new key goes last.
|
|
167
|
+
*/
|
|
168
|
+
export function withStatusLine(settings, entry) {
|
|
169
|
+
const result = {};
|
|
170
|
+
let replaced = false;
|
|
171
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
172
|
+
if (key === "statusLine") {
|
|
173
|
+
replaced = true;
|
|
174
|
+
// Replacing in position keeps the diff to the one changed entry.
|
|
175
|
+
if (entry !== undefined) {
|
|
176
|
+
result[key] = entry;
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
result[key] = value;
|
|
181
|
+
}
|
|
182
|
+
if (!replaced && entry !== undefined) {
|
|
183
|
+
result["statusLine"] = entry;
|
|
184
|
+
}
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Plans the settings change for `init`. Pure: nothing is written.
|
|
189
|
+
* @param settings - Current settings; `null` when the file doesn't exist.
|
|
190
|
+
* @param hookCommand - The command that runs our hook.
|
|
191
|
+
* @returns The plan: install (with the original to record), update our command, do nothing, or
|
|
192
|
+
* refuse because the existing entry isn't a command.
|
|
193
|
+
*/
|
|
194
|
+
export function planInstall(settings, hookCommand) {
|
|
195
|
+
const state = classifyStatusLine(settings, hookCommand);
|
|
196
|
+
const base = settings ?? {};
|
|
197
|
+
switch (state.kind) {
|
|
198
|
+
case "absent":
|
|
199
|
+
return {
|
|
200
|
+
action: "install",
|
|
201
|
+
settings: withStatusLine(base, { type: "command", command: hookCommand }),
|
|
202
|
+
original: null,
|
|
203
|
+
};
|
|
204
|
+
case "foreign":
|
|
205
|
+
// Keep every other field of the user's entry (padding and anything added later).
|
|
206
|
+
return {
|
|
207
|
+
action: "install",
|
|
208
|
+
settings: withStatusLine(base, { ...state.entry, command: hookCommand }),
|
|
209
|
+
original: state.entry,
|
|
210
|
+
};
|
|
211
|
+
case "ours":
|
|
212
|
+
// Running init twice must not wrap the wrapper.
|
|
213
|
+
return { action: "none", reason: "already-installed" };
|
|
214
|
+
case "ours-outdated":
|
|
215
|
+
return {
|
|
216
|
+
action: "update-command",
|
|
217
|
+
settings: withStatusLine(base, { ...state.entry, command: hookCommand }),
|
|
218
|
+
};
|
|
219
|
+
case "unsupported":
|
|
220
|
+
return { action: "refuse", reason: "unsupported-statusline", value: state.value };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Plans the settings change for `uninstall`. Pure: nothing is written.
|
|
225
|
+
* @param settings - Current settings; `null` when the file doesn't exist.
|
|
226
|
+
* @param original - The entry recorded at install time; `null` if there was none.
|
|
227
|
+
* @returns Restore the original entry (or remove ours), or do nothing. If the current entry isn't
|
|
228
|
+
* ours, the user changed it after install, and it's left alone rather than clobbered.
|
|
229
|
+
*/
|
|
230
|
+
export function planUninstall(settings, original) {
|
|
231
|
+
if (settings === null || !("statusLine" in settings)) {
|
|
232
|
+
return { action: "none", reason: "not-installed" };
|
|
233
|
+
}
|
|
234
|
+
const value = settings["statusLine"];
|
|
235
|
+
if (!isCommandEntry(value) || !value.command.includes(HOOK_MARKER)) {
|
|
236
|
+
return { action: "none", reason: "replaced-by-user" };
|
|
237
|
+
}
|
|
238
|
+
return { action: "restore", settings: withStatusLine(settings, original ?? undefined) };
|
|
239
|
+
}
|
|
240
|
+
//# sourceMappingURL=statusline.js.map
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Starting another program the same way on macOS, Linux, and Windows (D-051).
|
|
3
|
+
*
|
|
4
|
+
* On Windows the tools npm installs are `.cmd` shims, which Node refuses to spawn unless a shell
|
|
5
|
+
* runs them, so a command that works everywhere else fails there with ENOENT. Going through a shell
|
|
6
|
+
* brings two problems of its own: Node concatenates arguments without escaping them (DEP0190), so a
|
|
7
|
+
* path containing a space is split into two words, and `cmd.exe` reports a command it can't find as
|
|
8
|
+
* exit 1, which is indistinguishable from a tool that ran and failed.
|
|
9
|
+
*
|
|
10
|
+
* This module answers both: the command line is quoted here and passed as one pre-built string, and
|
|
11
|
+
* a command is looked up before it runs, so "not installed" is reported as itself.
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
/** Exit code reported when a command isn't installed, or couldn't be started. */
|
|
16
|
+
export const NOT_STARTED = 127;
|
|
17
|
+
/**
|
|
18
|
+
* `cmd.exe`'s code for "is not recognized as an internal or external command".
|
|
19
|
+
*
|
|
20
|
+
* Windows ships a `python3` stub that exits with this instead of running Python, so a command that
|
|
21
|
+
* returns it hasn't really run.
|
|
22
|
+
*/
|
|
23
|
+
export const WINDOWS_NOT_RECOGNIZED = 9009;
|
|
24
|
+
/** Characters that make `cmd.exe` read a word as something other than one plain argument. */
|
|
25
|
+
const NEEDS_QUOTING = /[\s&|<>^()%!]/;
|
|
26
|
+
/**
|
|
27
|
+
* Quotes one word of a `cmd.exe` command line.
|
|
28
|
+
* @param word - A command name or argument.
|
|
29
|
+
* @returns The word, in double quotes when it would otherwise be split or interpreted.
|
|
30
|
+
* @throws {Error} If the word contains a double quote. Nothing here needs one, and quoting it
|
|
31
|
+
* correctly depends on how the receiving program parses its arguments, so this refuses rather
|
|
32
|
+
* than building a command line that might mean something else (report, don't fix).
|
|
33
|
+
* @example
|
|
34
|
+
* quoteForCmd("C:\\Program Files\\nodejs\\node.exe"); // '"C:\\Program Files\\nodejs\\node.exe"'
|
|
35
|
+
*/
|
|
36
|
+
export function quoteForCmd(word) {
|
|
37
|
+
if (word.includes('"')) {
|
|
38
|
+
throw new Error(`cannot quote ${word} for cmd.exe: it contains a double quote`);
|
|
39
|
+
}
|
|
40
|
+
return word === "" || NEEDS_QUOTING.test(word) ? `"${word}"` : word;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Builds one `cmd.exe` command line from a command and its arguments.
|
|
44
|
+
* @param command - Executable name or path.
|
|
45
|
+
* @param args - Arguments, in order.
|
|
46
|
+
* @returns A single command line with every word quoted as it needs.
|
|
47
|
+
*/
|
|
48
|
+
export function windowsCommandLine(command, args) {
|
|
49
|
+
return [command, ...args].map(quoteForCmd).join(" ");
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Decides how to start a command on this platform.
|
|
53
|
+
*
|
|
54
|
+
* Windows gets one pre-quoted command line and no separate arguments, because Node only
|
|
55
|
+
* concatenates arguments when a shell is used and doesn't escape them. Everywhere else the command
|
|
56
|
+
* and arguments are passed as they are, with no shell involved at all.
|
|
57
|
+
* @param command - Executable name or path.
|
|
58
|
+
* @param args - Arguments, in order.
|
|
59
|
+
* @param platform - The platform; defaults to this one.
|
|
60
|
+
* @returns The command, arguments, and shell flag to spawn with.
|
|
61
|
+
*/
|
|
62
|
+
export function invocation(command, args, platform = process.platform) {
|
|
63
|
+
if (platform !== "win32") {
|
|
64
|
+
return { command, args, shell: false };
|
|
65
|
+
}
|
|
66
|
+
return { command: windowsCommandLine(command, args), args: [], shell: true };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Reports whether a command can be started, where that can't be told from its exit code.
|
|
70
|
+
*
|
|
71
|
+
* On POSIX a missing command reports itself (the spawn fails with ENOENT), so this always says yes
|
|
72
|
+
* and lets the spawn answer. On Windows a shell reports a missing command as exit 1, so the name is
|
|
73
|
+
* looked up first with `where.exe` — a real executable, which spawns without a shell.
|
|
74
|
+
* @param command - Executable name or path.
|
|
75
|
+
* @param deps - Injectable platform and lookups.
|
|
76
|
+
* @returns False only when Windows could not find the command.
|
|
77
|
+
*/
|
|
78
|
+
export function commandFound(command, deps = {}) {
|
|
79
|
+
const platform = deps.platform ?? process.platform;
|
|
80
|
+
if (platform !== "win32") {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
const exists = deps.exists ?? existsSync;
|
|
84
|
+
// A path is checked on disk: `where` takes patterns to search for, not paths that already resolve.
|
|
85
|
+
if (command.includes("/") || command.includes("\\")) {
|
|
86
|
+
return exists(command);
|
|
87
|
+
}
|
|
88
|
+
const probe = deps.probe ?? ((name) => spawnSync("where.exe", [name], { stdio: "ignore" }).status);
|
|
89
|
+
return probe(command) === 0;
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=commands.js.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The Node.js version check that runs before any command (D-066).
|
|
3
|
+
*
|
|
4
|
+
* The database is Node's built-in `node:sqlite` (D-093). On a Node.js older than 24 it's missing
|
|
5
|
+
* (Node 22.11 throws on import without a flag) or experimental (Node 22.23 prints a warning on
|
|
6
|
+
* every run), and the suite isn't run there. Before D-093 the reason was harsher: better-sqlite3's
|
|
7
|
+
* binary segfaulted under Node 22 (D-057, D-066). This module turns that into a sentence the user
|
|
8
|
+
* can act on. It imports nothing, so the entry point can call it before loading `node:sqlite`.
|
|
9
|
+
*/
|
|
10
|
+
/** The oldest Node.js major Nilometer runs on: `.nvmrc` (the development version) and `engines`. */
|
|
11
|
+
export const SUPPORTED_NODE_MAJOR = 24;
|
|
12
|
+
/**
|
|
13
|
+
* The majors the test suite runs on in CI: the long-term support lines from the minimum on (D-094).
|
|
14
|
+
* An odd major between them is short-lived and isn't run.
|
|
15
|
+
*/
|
|
16
|
+
export const TESTED_NODE_MAJORS = [24, 26];
|
|
17
|
+
/** The tested majors as words, e.g. "24 or 26". */
|
|
18
|
+
const TESTED_TEXT = TESTED_NODE_MAJORS.join(" or ");
|
|
19
|
+
/** Where the install guide lives, relative to the repository root. */
|
|
20
|
+
export const REQUIREMENTS_DOC = "docs/requirements.md";
|
|
21
|
+
/**
|
|
22
|
+
* Decides whether a Node.js version can run Nilometer, and what to tell the user.
|
|
23
|
+
*
|
|
24
|
+
* Older majors stop: `node:sqlite` is missing or experimental there (D-093). A tested major runs
|
|
25
|
+
* quietly. Any other major from the minimum on runs with a warning, since no CI job runs it (D-066,
|
|
26
|
+
* D-094): Node 26 was such a major until SQLite 3.54 broke two views on it.
|
|
27
|
+
* @param version - `process.versions.node`, such as `24.21.0` (no leading `v`).
|
|
28
|
+
* @returns The status and the message to print, if any.
|
|
29
|
+
* @example
|
|
30
|
+
* checkNodeVersion("24.21.0").status; // "ok"
|
|
31
|
+
* checkNodeVersion("22.11.0").status; // "too_old"
|
|
32
|
+
*/
|
|
33
|
+
export function checkNodeVersion(version) {
|
|
34
|
+
// `process.versions.node` has no "v"; tolerate one anyway so a caller passing
|
|
35
|
+
// `process.version` gets the same answer.
|
|
36
|
+
const match = /^v?(\d+)\.\d+\.\d+/.exec(version);
|
|
37
|
+
if (match === null) {
|
|
38
|
+
return {
|
|
39
|
+
status: "unrecognized",
|
|
40
|
+
message: `Nilometer couldn't read the Node.js version ("${version}"). It needs Node.js ${TESTED_TEXT}; see ${REQUIREMENTS_DOC} if a command fails.`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const major = Number(match[1]);
|
|
44
|
+
if (major < SUPPORTED_NODE_MAJOR) {
|
|
45
|
+
return {
|
|
46
|
+
status: "too_old",
|
|
47
|
+
message: `Nilometer needs Node.js ${SUPPORTED_NODE_MAJOR} or newer, and this is Node.js ${version}. Older versions lack the SQLite built into Node.js ${SUPPORTED_NODE_MAJOR}, or have only an experimental one. Install Node.js ${TESTED_TEXT} (see ${REQUIREMENTS_DOC} in the Nilometer repository) and run the command again. Nothing was changed.`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (!TESTED_NODE_MAJORS.includes(major)) {
|
|
51
|
+
return {
|
|
52
|
+
status: "untested",
|
|
53
|
+
message: `Nilometer is tested on Node.js ${TESTED_TEXT}, and this is Node.js ${version}. Other versions aren't tested; if a command fails, switch to Node.js ${TESTED_TEXT} (see ${REQUIREMENTS_DOC} in the Nilometer repository).`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return { status: "ok", message: undefined };
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=node-version.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Opening a saved HTML page with the platform's own opener (D-082, step G2.10).
|
|
3
|
+
*
|
|
4
|
+
* `report --save --html --open` hands the page to whatever opens files on this machine: `open` on
|
|
5
|
+
* macOS, `start` on Windows, `xdg-open` elsewhere. Nothing here decides which browser runs. A failed
|
|
6
|
+
* open is reported to the caller, which still succeeds: the page is saved either way, and over SSH
|
|
7
|
+
* or in a script there may be no browser to open.
|
|
8
|
+
*/
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { invocation } from "./commands.js";
|
|
11
|
+
/** How long an opener may take before it's taken as not having opened the page. */
|
|
12
|
+
const OPENER_TIMEOUT_MS = 15_000;
|
|
13
|
+
/**
|
|
14
|
+
* Names the platform's opener for a file.
|
|
15
|
+
* @param path - The file to open.
|
|
16
|
+
* @param platform - The platform.
|
|
17
|
+
* @returns The command and arguments.
|
|
18
|
+
* @example
|
|
19
|
+
* openerFor("/tmp/report.html", "darwin"); // { command: "open", args: ["/tmp/report.html"] }
|
|
20
|
+
*/
|
|
21
|
+
export function openerFor(path, platform) {
|
|
22
|
+
if (platform === "darwin") {
|
|
23
|
+
return { command: "open", args: [path] };
|
|
24
|
+
}
|
|
25
|
+
if (platform === "win32") {
|
|
26
|
+
// start is a cmd.exe builtin, and its first quoted word is a window title; the empty title
|
|
27
|
+
// keeps a quoted path from being read as one.
|
|
28
|
+
return { command: "start", args: ["", path] };
|
|
29
|
+
}
|
|
30
|
+
return { command: "xdg-open", args: [path] };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Runs an opener and waits for it to exit. `open`, `start`, and `xdg-open` hand the file over and
|
|
34
|
+
* return; the timeout covers one that doesn't.
|
|
35
|
+
* @param call - The command, arguments, and shell flag from {@link invocation}.
|
|
36
|
+
* @returns Its exit code, and the error when it couldn't start or timed out.
|
|
37
|
+
*/
|
|
38
|
+
export function spawnOpener(call) {
|
|
39
|
+
const result = spawnSync(call.command, call.args, {
|
|
40
|
+
shell: call.shell,
|
|
41
|
+
// Nothing the opener prints belongs in the report's output, which may be JSON on stdout.
|
|
42
|
+
stdio: "ignore",
|
|
43
|
+
timeout: OPENER_TIMEOUT_MS,
|
|
44
|
+
});
|
|
45
|
+
return { status: result.status, error: result.error };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Opens a saved page with the platform's opener (D-082).
|
|
49
|
+
* @param path - The saved page.
|
|
50
|
+
* @param deps - Injectable platform and process start.
|
|
51
|
+
* @returns Opened, or why not.
|
|
52
|
+
*/
|
|
53
|
+
export function openPage(path, deps = {}) {
|
|
54
|
+
const platform = deps.platform ?? process.platform;
|
|
55
|
+
const run = deps.run ?? spawnOpener;
|
|
56
|
+
const opener = openerFor(path, platform);
|
|
57
|
+
const result = run(invocation(opener.command, opener.args, platform));
|
|
58
|
+
if (result.error !== undefined) {
|
|
59
|
+
return { opened: false, reason: `${opener.command} couldn't run: ${result.error.message}` };
|
|
60
|
+
}
|
|
61
|
+
if (result.status !== 0) {
|
|
62
|
+
return {
|
|
63
|
+
opened: false,
|
|
64
|
+
reason: `${opener.command} exited with ${result.status === null ? "no code" : String(result.status)}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { opened: true };
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=open-page.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Path helpers shared by ingestion and `init`.
|
|
3
|
+
*
|
|
4
|
+
* Implements README § How it works (session logs are read from `~/.claude/projects/`).
|
|
5
|
+
* Paths in configuration and environment variables may start with `~`, which Node's `fs`
|
|
6
|
+
* does not expand, so every path from outside the program passes through {@link expandHome}.
|
|
7
|
+
*/
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
/**
|
|
11
|
+
* Expands a leading `~` in a path to the given home directory.
|
|
12
|
+
*
|
|
13
|
+
* Only the current user's home is supported: `~` alone and `~/...`. A `~user/...` form is
|
|
14
|
+
* returned unchanged, because resolving another user's home needs a system lookup this tool
|
|
15
|
+
* has no reason to make.
|
|
16
|
+
* @param path - A filesystem path, possibly starting with `~`.
|
|
17
|
+
* @param home - The home directory to substitute. Defaults to the current user's home; tests
|
|
18
|
+
* pass a fixed value so results don't depend on the machine.
|
|
19
|
+
* @returns The path with `~` expanded, or the input unchanged if it has no leading `~`.
|
|
20
|
+
* @example
|
|
21
|
+
* expandHome("~/.claude/projects", "/home/example"); // "/home/example/.claude/projects"
|
|
22
|
+
* expandHome("/tmp/logs", "/home/example"); // "/tmp/logs"
|
|
23
|
+
*/
|
|
24
|
+
export function expandHome(path, home = homedir()) {
|
|
25
|
+
// A bare "~" means the home directory itself.
|
|
26
|
+
if (path === "~") {
|
|
27
|
+
return home;
|
|
28
|
+
}
|
|
29
|
+
// Only "~/" is expanded. "~user/" and "~" in the middle of a path are left alone on purpose.
|
|
30
|
+
if (path.startsWith("~/")) {
|
|
31
|
+
// join() normalizes separators, so "~//x" still becomes "<home>/x".
|
|
32
|
+
return join(home, path.slice(2));
|
|
33
|
+
}
|
|
34
|
+
return path;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=paths.js.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Comparing token totals against ccusage, shared by the fixture audit and `nilometer verify`.
|
|
3
|
+
*
|
|
4
|
+
* The rules for "do these totals agree" live here once. The audit (check A7) applies them to the
|
|
5
|
+
* committed fixtures; `verify` applies the same rules to a user's own logs on their own machine
|
|
6
|
+
* (D-064). Two implementations of the same question would be free to drift, and the whole point of
|
|
7
|
+
* comparing against a reference implementation is that the comparison itself is trustworthy.
|
|
8
|
+
*/
|
|
9
|
+
/** The fields compared, in the order they're reported. */
|
|
10
|
+
export const FIELDS = ["input", "output", "cache_read", "cache_write", "cost_usd"];
|
|
11
|
+
/**
|
|
12
|
+
* Normalizes ccusage daily JSON to per-day, per-model totals.
|
|
13
|
+
* @param json - Parsed output of `ccusage claude daily --json --breakdown`.
|
|
14
|
+
* @returns Totals keyed by `"<day>|<model>"`.
|
|
15
|
+
*/
|
|
16
|
+
export function normalizeCcusage(json) {
|
|
17
|
+
const result = {};
|
|
18
|
+
for (const day of json.daily) {
|
|
19
|
+
for (const model of day.modelBreakdowns) {
|
|
20
|
+
result[`${day.date}|${model.modelName}`] = {
|
|
21
|
+
input: model.inputTokens,
|
|
22
|
+
output: model.outputTokens,
|
|
23
|
+
cache_read: model.cacheReadTokens,
|
|
24
|
+
// ccusage reports one cache-write total; its JSON has no 5m/1h split (skill § Comparing).
|
|
25
|
+
cache_write: model.cacheCreationTokens,
|
|
26
|
+
cost_usd: model.cost,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Compares two values of a field: token counts exactly, costs within floating-point noise.
|
|
34
|
+
* @param field - The field compared.
|
|
35
|
+
* @param a - One value.
|
|
36
|
+
* @param b - The other value.
|
|
37
|
+
* @returns True when the values are equal for that field.
|
|
38
|
+
*/
|
|
39
|
+
export function sameValue(field, a, b) {
|
|
40
|
+
if (field !== "cost_usd") {
|
|
41
|
+
return a === b;
|
|
42
|
+
}
|
|
43
|
+
// Summing binary floats in a different order differs in the 17th digit; a millionth of a cent doesn't.
|
|
44
|
+
return Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Lists every field that differs between two totals maps.
|
|
48
|
+
* @param ours - Nilometer's totals.
|
|
49
|
+
* @param theirs - ccusage's totals.
|
|
50
|
+
* @param fields - Fields to compare; defaults to all of them.
|
|
51
|
+
* @returns Differences sorted by key, then field order.
|
|
52
|
+
*/
|
|
53
|
+
export function diffTotals(ours, theirs, fields = FIELDS) {
|
|
54
|
+
const differences = [];
|
|
55
|
+
const keys = [...new Set([...Object.keys(ours), ...Object.keys(theirs)])].sort();
|
|
56
|
+
for (const key of keys) {
|
|
57
|
+
for (const field of fields) {
|
|
58
|
+
// A day/model missing on one side compares as zero, so it shows up as a difference.
|
|
59
|
+
const oursValue = ours[key]?.[field] ?? 0;
|
|
60
|
+
const theirsValue = theirs[key]?.[field] ?? 0;
|
|
61
|
+
if (!sameValue(field, oursValue, theirsValue)) {
|
|
62
|
+
differences.push({ key, field, ours: oursValue, theirs: theirsValue });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return differences;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Takes the day out of a `"<day>|<model>"` key.
|
|
70
|
+
* @param key - The key.
|
|
71
|
+
* @returns The `YYYY-MM-DD` part.
|
|
72
|
+
*/
|
|
73
|
+
export function dayOf(key) {
|
|
74
|
+
// split always yields at least one element, so this needs no fallback.
|
|
75
|
+
return key.split("|")[0];
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=compare.js.map
|