nilometer 0.0.0-stage → 0.2.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 +68 -0
- package/LICENSE +21 -0
- package/README.md +464 -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/core/schema/032_stored_views.sql +233 -0
- package/dist/cli/main.cli.js +39 -0
- package/dist/cli/program.js +881 -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 +219 -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/ingest/stored-views.js +65 -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/core/view/server.js +142 -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 +1717 -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,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The `init` and `uninstall` operations: register the status line hook, and reverse it.
|
|
3
|
+
*
|
|
4
|
+
* Implements README § How it works ("Chaining, not clobbering"), docs/development.md P1.3, and the
|
|
5
|
+
* `statusline-collector` skill's `init` steps: back up first, wrap an existing command, write
|
|
6
|
+
* atomically, be idempotent, and make `uninstall` restore the original exactly.
|
|
7
|
+
*
|
|
8
|
+
* Both operations return a plain outcome object. Wording for the user belongs to the CLI layer,
|
|
9
|
+
* so outcomes can be tested without asserting on prose.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { isDeepStrictEqual } from "node:util";
|
|
14
|
+
import { backupSettings, locateSettings, readSettings, serializeSettings, writeFileAtomic, } from "../settings/settings-file.js";
|
|
15
|
+
import { buildHookCommand, dataDirFromHookCommand, isCommandEntry, planInstall, planUninstall, } from "../settings/statusline.js";
|
|
16
|
+
import { HOOK_RELATIVE_PATH, INSTALLED_HOOK_FILE, resolveDataDir } from "./locations.js";
|
|
17
|
+
import { ensurePrivateDir, installHook, removePath } from "./private-files.js";
|
|
18
|
+
import { readInstallRecord, removeInstallRecord, writeInstallRecord } from "./record.js";
|
|
19
|
+
/** Layout used when `init` creates a settings file from scratch: what Claude Code writes. */
|
|
20
|
+
export const DEFAULT_FORMAT = { indent: 2, trailingNewline: true };
|
|
21
|
+
/**
|
|
22
|
+
* Resolves the settings path, data directory, and hook command for a run.
|
|
23
|
+
* @param options - Install options.
|
|
24
|
+
* @returns The three resolved locations.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveTargets(options) {
|
|
27
|
+
const settingsPath = locateSettings({
|
|
28
|
+
home: options.home,
|
|
29
|
+
override: options.settingsOverride,
|
|
30
|
+
claudeConfigDir: options.env.CLAUDE_CONFIG_DIR,
|
|
31
|
+
});
|
|
32
|
+
const dataDir = resolveDataDir({
|
|
33
|
+
home: options.home,
|
|
34
|
+
env: options.env,
|
|
35
|
+
override: options.dataDirOverride,
|
|
36
|
+
});
|
|
37
|
+
// The command names the copy in the data directory, which nothing moves, not the one in the
|
|
38
|
+
// package, whose path changes with the Node version or the checkout's location (D-056).
|
|
39
|
+
const hookPath = join(dataDir, INSTALLED_HOOK_FILE);
|
|
40
|
+
const hookCommand = buildHookCommand(hookPath, dataDir);
|
|
41
|
+
return { settingsPath, dataDir, hookCommand, hookPath };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Registers the status line hook in Claude Code's settings. Safe to run repeatedly.
|
|
45
|
+
* @param options - Locations, environment, and clock.
|
|
46
|
+
* @returns What happened, with the paths involved.
|
|
47
|
+
* @throws {import("../settings/settings-file.js").SettingsError} If the settings file can't be
|
|
48
|
+
* parsed; nothing is changed.
|
|
49
|
+
* @throws {import("./record.js").InstallRecordError} If an existing install record is corrupt.
|
|
50
|
+
*/
|
|
51
|
+
export function runInit(options) {
|
|
52
|
+
const { settingsPath, dataDir, hookCommand, hookPath } = resolveTargets(options);
|
|
53
|
+
const read = readSettings(settingsPath);
|
|
54
|
+
const settings = read.kind === "ok" ? read.settings : null;
|
|
55
|
+
const plan = planInstall(settings, hookCommand);
|
|
56
|
+
// Before the settings can name it, and on every run: a package whose hook changed needs the copy
|
|
57
|
+
// refreshed, even when the command itself is already correct (D-056).
|
|
58
|
+
const platform = options.platform ?? process.platform;
|
|
59
|
+
let hookRefreshed = false;
|
|
60
|
+
if (plan.action !== "refuse") {
|
|
61
|
+
ensurePrivateDir(dataDir, platform);
|
|
62
|
+
hookRefreshed = installHook(join(options.packageRoot, HOOK_RELATIVE_PATH), hookPath, platform);
|
|
63
|
+
}
|
|
64
|
+
const base = { settingsPath, dataDir, hookRefreshed };
|
|
65
|
+
switch (plan.action) {
|
|
66
|
+
case "none":
|
|
67
|
+
return { ...base, action: "already-installed", backupPath: null, wrappedCommand: wrapped() };
|
|
68
|
+
case "refuse":
|
|
69
|
+
return { ...base, action: "refused-unsupported", backupPath: null, wrappedCommand: null };
|
|
70
|
+
case "update-command": {
|
|
71
|
+
// The record lives in the data directory. If this data directory has none, the hook was
|
|
72
|
+
// installed with another one, and moving on would lose the user's original command.
|
|
73
|
+
if (readInstallRecord(dataDir) === null) {
|
|
74
|
+
return { ...base, action: "refused-other-install", backupPath: null, wrappedCommand: null };
|
|
75
|
+
}
|
|
76
|
+
const backupPath = backupSettings(settingsPath, options.now);
|
|
77
|
+
writeSettings(settingsPath, plan.settings, read.kind === "ok" ? read.format : DEFAULT_FORMAT);
|
|
78
|
+
return { ...base, action: "updated", backupPath, wrappedCommand: wrapped() };
|
|
79
|
+
}
|
|
80
|
+
case "install": {
|
|
81
|
+
const backupPath = backupSettings(settingsPath, options.now);
|
|
82
|
+
// Record first: once settings point at the hook, its next run must find the original command.
|
|
83
|
+
writeInstallRecord(dataDir, {
|
|
84
|
+
version: 1,
|
|
85
|
+
settingsPath,
|
|
86
|
+
settingsExisted: read.kind === "ok",
|
|
87
|
+
backupPath,
|
|
88
|
+
original: plan.original,
|
|
89
|
+
installedAt: options.now.toISOString(),
|
|
90
|
+
});
|
|
91
|
+
writeSettings(settingsPath, plan.settings, read.kind === "ok" ? read.format : DEFAULT_FORMAT);
|
|
92
|
+
return { ...base, action: "installed", backupPath, wrappedCommand: wrapped() };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Reads which original command the hook wraps, from the record in this data directory.
|
|
97
|
+
* @returns The wrapped command, or null if there is none.
|
|
98
|
+
*/
|
|
99
|
+
function wrapped() {
|
|
100
|
+
return readInstallRecord(dataDir)?.original?.command ?? null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Serializes settings and writes them atomically, creating the directory if needed.
|
|
105
|
+
* @param path - Settings file path.
|
|
106
|
+
* @param settings - Settings to write.
|
|
107
|
+
* @param format - Layout to use.
|
|
108
|
+
*/
|
|
109
|
+
function writeSettings(path, settings, format) {
|
|
110
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
111
|
+
writeFileAtomic(path, serializeSettings(settings, format));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Removes the hook from Claude Code's settings and restores what `init` replaced.
|
|
115
|
+
* Collected data (spool, error log) is kept.
|
|
116
|
+
* @param options - Locations, environment, and clock.
|
|
117
|
+
* @returns What happened, with the paths involved.
|
|
118
|
+
* @throws {import("../settings/settings-file.js").SettingsError} If the settings file can't be
|
|
119
|
+
* parsed; nothing is changed.
|
|
120
|
+
* @throws {import("./record.js").InstallRecordError} If the install record is corrupt.
|
|
121
|
+
*/
|
|
122
|
+
export function runUninstall(options) {
|
|
123
|
+
const targets = resolveTargets(options);
|
|
124
|
+
let dataDir = targets.dataDir;
|
|
125
|
+
let record = readInstallRecord(dataDir);
|
|
126
|
+
// The record knows which file was changed, even if flags or environment differ today.
|
|
127
|
+
const settingsPath = record?.settingsPath ?? targets.settingsPath;
|
|
128
|
+
const read = readSettings(settingsPath);
|
|
129
|
+
if (record === null && read.kind === "ok") {
|
|
130
|
+
// `init --data-dir X` followed by a plain `uninstall` looks for the record in the default
|
|
131
|
+
// directory and doesn't find it. Without the record, the plan below would delete the user's
|
|
132
|
+
// own status line instead of restoring it, so recover the directory from the command that's
|
|
133
|
+
// about to be removed — the one other place `init` wrote that path down (D-050).
|
|
134
|
+
const entry = read.settings["statusLine"];
|
|
135
|
+
const installedAt = isCommandEntry(entry) ? dataDirFromHookCommand(entry.command) : null;
|
|
136
|
+
const recovered = installedAt === null ? null : readInstallRecord(installedAt);
|
|
137
|
+
if (installedAt !== null && recovered !== null) {
|
|
138
|
+
record = recovered;
|
|
139
|
+
dataDir = installedAt;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const settings = read.kind === "ok" ? read.settings : null;
|
|
143
|
+
const plan = planUninstall(settings, record?.original ?? null);
|
|
144
|
+
const base = {
|
|
145
|
+
settingsPath,
|
|
146
|
+
dataDir,
|
|
147
|
+
recordMissing: record === null,
|
|
148
|
+
restoredCommand: record?.original?.command ?? null,
|
|
149
|
+
notRemoved: [],
|
|
150
|
+
};
|
|
151
|
+
if (plan.action === "none" || read.kind !== "ok") {
|
|
152
|
+
const action = plan.action === "none" ? plan.reason : "not-installed";
|
|
153
|
+
return { ...base, action, backupPath: null, exactBytes: false };
|
|
154
|
+
}
|
|
155
|
+
const backupPath = backupSettings(settingsPath, options.now);
|
|
156
|
+
// init created this file and nothing else was added since: the faithful undo is no file at all.
|
|
157
|
+
if (record !== null && !record.settingsExisted && Object.keys(plan.settings).length === 0) {
|
|
158
|
+
// Verified, not assumed: a removal that silently does nothing would leave the hook registered
|
|
159
|
+
// while `uninstall` reported success, and it would keep recording (D-061).
|
|
160
|
+
const gone = removePath(settingsPath);
|
|
161
|
+
removeInstallRecord(dataDir);
|
|
162
|
+
return {
|
|
163
|
+
...base,
|
|
164
|
+
action: "removed-settings-file",
|
|
165
|
+
backupPath,
|
|
166
|
+
exactBytes: true,
|
|
167
|
+
notRemoved: gone ? [] : [settingsPath],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
// If the result equals the pre-install file, write its exact bytes: a re-serialization could
|
|
171
|
+
// differ in whitespace the JSON parser discarded.
|
|
172
|
+
const original = record?.backupPath ?? null;
|
|
173
|
+
if (original !== null && existsSync(original)) {
|
|
174
|
+
const originalRaw = readFileSync(original, "utf8");
|
|
175
|
+
if (isDeepStrictEqual(JSON.parse(originalRaw), plan.settings)) {
|
|
176
|
+
writeFileAtomic(settingsPath, originalRaw);
|
|
177
|
+
removeInstallRecord(dataDir);
|
|
178
|
+
return { ...base, action: "restored", backupPath, exactBytes: true };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
writeFileAtomic(settingsPath, serializeSettings(plan.settings, read.format));
|
|
182
|
+
removeInstallRecord(dataDir);
|
|
183
|
+
return { ...base, action: "restored", backupPath, exactBytes: false };
|
|
184
|
+
}
|
|
185
|
+
//# sourceMappingURL=install.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Where the tool keeps its data and where the hook script lives.
|
|
3
|
+
*
|
|
4
|
+
* Implements docs/development.md P1.3 and the `statusline-collector` skill ("Spool path: under the tool's data
|
|
5
|
+
* directory, never inside `~/.claude`"). The data directory holds the spool, the hook's error log,
|
|
6
|
+
* and the install record; later phases add the database there.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join, resolve } from "node:path";
|
|
10
|
+
import { expandHome } from "../util/paths.js";
|
|
11
|
+
/** Directory name used under the XDG data home. */
|
|
12
|
+
export const DATA_DIR_NAME = "nilometer";
|
|
13
|
+
/**
|
|
14
|
+
* Resolves the tool's data directory.
|
|
15
|
+
*
|
|
16
|
+
* Order: `--data-dir`, then `$NILOMETER_HOME`, then `$XDG_DATA_HOME/nilometer`,
|
|
17
|
+
* then `~/.local/share/nilometer`. XDG's default is used on macOS too, so every platform
|
|
18
|
+
* has one documented location instead of a per-OS guess.
|
|
19
|
+
* @param options - Home directory, environment, and flag override.
|
|
20
|
+
* @returns An absolute data directory path (it may not exist yet).
|
|
21
|
+
*/
|
|
22
|
+
export function resolveDataDir(options) {
|
|
23
|
+
const explicit = [options.override, options.env.NILOMETER_HOME].find((value) => value !== undefined && value !== "");
|
|
24
|
+
if (explicit !== undefined) {
|
|
25
|
+
// Always absolute (D-050): `init` writes this path into the status line command, and the hook
|
|
26
|
+
// resolves a relative one against whatever project Claude Code runs in. A folder committed to a
|
|
27
|
+
// repository could then supply the wrapped command the hook executes.
|
|
28
|
+
return resolve(expandHome(explicit, options.home));
|
|
29
|
+
}
|
|
30
|
+
const xdg = options.env.XDG_DATA_HOME;
|
|
31
|
+
// XDG says relative values are invalid and must be ignored.
|
|
32
|
+
if (xdg !== undefined && xdg.startsWith("/")) {
|
|
33
|
+
return join(xdg, DATA_DIR_NAME);
|
|
34
|
+
}
|
|
35
|
+
return join(options.home, ".local", "share", DATA_DIR_NAME);
|
|
36
|
+
}
|
|
37
|
+
/** Path of the hook script relative to the package root. */
|
|
38
|
+
export const HOOK_RELATIVE_PATH = join("hooks", "statusline.sh");
|
|
39
|
+
/**
|
|
40
|
+
* The hook's name inside the data directory, where `init` installs a copy of it (D-056).
|
|
41
|
+
*
|
|
42
|
+
* The status line command has to name a path that nothing moves. A path inside the package moves
|
|
43
|
+
* whenever the package does: a global npm install lives under the Node version in use, so upgrading
|
|
44
|
+
* Node leaves the command pointing at a file that no longer exists, and readings stop with no
|
|
45
|
+
* error. The data directory is chosen by the user and never moves on its own.
|
|
46
|
+
*/
|
|
47
|
+
export const INSTALLED_HOOK_FILE = "statusline.sh";
|
|
48
|
+
/**
|
|
49
|
+
* Finds the package root by walking up from a directory until `hooks/statusline.sh` and
|
|
50
|
+
* `package.json` both exist.
|
|
51
|
+
*
|
|
52
|
+
* Works whether code runs from source (`core/install/`) or from the build (`dist/core/install/`),
|
|
53
|
+
* because the build doesn't copy the shell script.
|
|
54
|
+
* @param startDir - Directory to start from, usually this module's own directory.
|
|
55
|
+
* @param exists - File existence check, injectable for tests.
|
|
56
|
+
* @returns The package root directory.
|
|
57
|
+
* @throws {Error} If no ancestor contains both files.
|
|
58
|
+
*/
|
|
59
|
+
export function findPackageRoot(startDir, exists = existsSync) {
|
|
60
|
+
let current = startDir;
|
|
61
|
+
for (;;) {
|
|
62
|
+
if (exists(join(current, "package.json")) && exists(join(current, HOOK_RELATIVE_PATH))) {
|
|
63
|
+
return current;
|
|
64
|
+
}
|
|
65
|
+
const parent = dirname(current);
|
|
66
|
+
// dirname of the filesystem root is the root itself: nowhere left to look.
|
|
67
|
+
if (parent === current) {
|
|
68
|
+
throw new Error(`could not find ${HOOK_RELATIVE_PATH} above ${startDir}`);
|
|
69
|
+
}
|
|
70
|
+
current = parent;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Reads the package's own version, for `nilometer --version` (G5.5).
|
|
75
|
+
* @param packageRoot - From {@link findPackageRoot}.
|
|
76
|
+
* @param read - Reads a file's text, injectable for tests.
|
|
77
|
+
* @returns The `version` in its package.json, or `unknown` when that can't be read: the version is
|
|
78
|
+
* only ever printed, so a damaged file shouldn't stop a command.
|
|
79
|
+
*/
|
|
80
|
+
export function packageVersion(packageRoot, read = (path) => readFileSync(path, "utf8")) {
|
|
81
|
+
try {
|
|
82
|
+
const { version } = JSON.parse(read(join(packageRoot, "package.json")));
|
|
83
|
+
return typeof version === "string" ? version : "unknown";
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return "unknown";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=locations.js.map
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Owner-only storage for Nilometer's data (D-043).
|
|
3
|
+
*
|
|
4
|
+
* The database holds copies of session log lines (prompts, code, file paths) and the spool holds
|
|
5
|
+
* status line payloads. Claude Code keeps its own transcripts owner-only (`~/.claude` 0700,
|
|
6
|
+
* transcripts 0600; observed 2026-09-13), so Nilometer's copies must be no easier to read:
|
|
7
|
+
* directories 0700, files 0600. New paths are created that way, and existing installs are tightened
|
|
8
|
+
* the next time Nilometer opens them. Nothing here ever loosens a mode.
|
|
9
|
+
*
|
|
10
|
+
* On Windows none of this applies (D-049): Node's `chmod` there only toggles the read-only attribute,
|
|
11
|
+
* so a mode can't be tightened and reads back as 666 whatever was set. Tightening is skipped rather
|
|
12
|
+
* than reported as done, and the data relies on the user profile folder's permissions.
|
|
13
|
+
*/
|
|
14
|
+
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
/** Mode for directories Nilometer owns. */
|
|
17
|
+
export const PRIVATE_DIR_MODE = 0o700;
|
|
18
|
+
/** Mode for files Nilometer writes. */
|
|
19
|
+
export const PRIVATE_FILE_MODE = 0o600;
|
|
20
|
+
/** Files Nilometer keeps in its data directory; their presence also identifies one. */
|
|
21
|
+
export const DATA_DIR_FILES = [
|
|
22
|
+
"usage.db",
|
|
23
|
+
"usage.db-wal",
|
|
24
|
+
"usage.db-shm",
|
|
25
|
+
"statusline.spool.jsonl",
|
|
26
|
+
"hook-errors.log",
|
|
27
|
+
"install-record.json",
|
|
28
|
+
"wrapped-command",
|
|
29
|
+
"statusline.sh",
|
|
30
|
+
];
|
|
31
|
+
/** Subdirectories of the data directory whose files are all Nilometer's. */
|
|
32
|
+
export const DATA_DIR_SUBDIRS = ["reports"];
|
|
33
|
+
/**
|
|
34
|
+
* Tells whether POSIX permission modes mean anything on a platform.
|
|
35
|
+
* @param platform - A `process.platform` value.
|
|
36
|
+
* @returns False on Windows, where `chmod` only toggles read-only (D-049).
|
|
37
|
+
*/
|
|
38
|
+
export function hasPosixModes(platform) {
|
|
39
|
+
return platform !== "win32";
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Removes group and other permission bits from an existing path, never loosening anything.
|
|
43
|
+
* @param path - A file or directory.
|
|
44
|
+
* @param platform - The platform, for tests; defaults to this one.
|
|
45
|
+
* @returns True when the mode changed; false when it was already owner-only, is missing, is a
|
|
46
|
+
* symbolic link (chmod would follow the link and change a file Nilometer doesn't own), or the
|
|
47
|
+
* platform has no POSIX modes.
|
|
48
|
+
*/
|
|
49
|
+
export function tightenMode(path, platform = process.platform) {
|
|
50
|
+
if (!hasPosixModes(platform) || !existsSync(path)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const stat = lstatSync(path);
|
|
54
|
+
if (stat.isSymbolicLink()) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
const mode = stat.mode & 0o777;
|
|
58
|
+
if ((mode & 0o077) === 0) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
chmodSync(path, mode & 0o700);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Creates a directory owner-only if it doesn't exist.
|
|
66
|
+
* @param dir - The directory.
|
|
67
|
+
* @param platform - The platform, for tests; defaults to this one.
|
|
68
|
+
* @returns True when this call created it.
|
|
69
|
+
*/
|
|
70
|
+
export function ensurePrivateDir(dir, platform = process.platform) {
|
|
71
|
+
if (existsSync(dir)) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
mkdirSync(dir, { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
75
|
+
if (hasPosixModes(platform)) {
|
|
76
|
+
// mkdir's mode is filtered by the umask, and only the last component gets it exactly; chmod makes it certain.
|
|
77
|
+
chmodSync(dir, PRIVATE_DIR_MODE);
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Creates an empty file owner-only if it doesn't exist, so a library that then opens it keeps that mode.
|
|
83
|
+
* @param path - The file.
|
|
84
|
+
* @param platform - The platform, for tests; defaults to this one.
|
|
85
|
+
* @returns True when this call created it.
|
|
86
|
+
* @throws {Error} If the file can't be created for a reason other than already existing.
|
|
87
|
+
*/
|
|
88
|
+
export function ensurePrivateFile(path, platform = process.platform) {
|
|
89
|
+
if (existsSync(path)) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
// "wx": another process creating it first is fine; this only needs the file to exist privately.
|
|
93
|
+
try {
|
|
94
|
+
closeSync(openSync(path, "wx", PRIVATE_FILE_MODE));
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (error.code !== "EEXIST") {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
if (hasPosixModes(platform)) {
|
|
103
|
+
chmodSync(path, PRIVATE_FILE_MODE);
|
|
104
|
+
}
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Makes a data directory and everything Nilometer keeps in it owner-only.
|
|
109
|
+
*
|
|
110
|
+
* A directory created here is private from the start. An existing directory is tightened only if it
|
|
111
|
+
* already holds a Nilometer file, so pointing `--data-dir` at a shared folder by mistake never
|
|
112
|
+
* changes that folder's permissions; Nilometer's own files inside it are still tightened.
|
|
113
|
+
* @param dataDir - The data directory.
|
|
114
|
+
* @param platform - The platform, for tests; defaults to this one.
|
|
115
|
+
* @returns Paths whose permissions were tightened (empty for a new or already private directory,
|
|
116
|
+
* and always empty on Windows, where there's nothing to tighten; D-049).
|
|
117
|
+
*/
|
|
118
|
+
export function securePrivateDataDir(dataDir, platform = process.platform) {
|
|
119
|
+
if (ensurePrivateDir(dataDir, platform) || !hasPosixModes(platform)) {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
const tightened = [];
|
|
123
|
+
const owned = DATA_DIR_FILES.some((name) => existsSync(join(dataDir, name)));
|
|
124
|
+
if (owned && tightenMode(dataDir)) {
|
|
125
|
+
tightened.push(dataDir);
|
|
126
|
+
}
|
|
127
|
+
for (const name of DATA_DIR_FILES) {
|
|
128
|
+
const path = join(dataDir, name);
|
|
129
|
+
if (tightenMode(path)) {
|
|
130
|
+
tightened.push(path);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const sub of DATA_DIR_SUBDIRS) {
|
|
134
|
+
const dir = join(dataDir, sub);
|
|
135
|
+
if (!existsSync(dir) || lstatSync(dir).isSymbolicLink()) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (tightenMode(dir)) {
|
|
139
|
+
tightened.push(dir);
|
|
140
|
+
}
|
|
141
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
142
|
+
const path = join(dir, entry);
|
|
143
|
+
if (tightenMode(path)) {
|
|
144
|
+
tightened.push(path);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return tightened;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Describes a tightening for the user, once, when it happens.
|
|
152
|
+
* @param tightened - Paths from {@link securePrivateDataDir}.
|
|
153
|
+
* @returns One line, or null when nothing changed.
|
|
154
|
+
*/
|
|
155
|
+
export function describeTightened(tightened) {
|
|
156
|
+
if (tightened.length === 0) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const noun = tightened.length === 1 ? "path was" : "paths were";
|
|
160
|
+
return `Made Nilometer's data owner-only: ${tightened.length} ${noun} readable by other accounts on this computer.`;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Installs the hook script into the data directory, replacing an older copy.
|
|
164
|
+
*
|
|
165
|
+
* The status line command names this copy, not the one in the package, so upgrading Node or moving
|
|
166
|
+
* the checkout can't leave the command pointing at nothing (D-056). Written through a temporary
|
|
167
|
+
* file and a rename, so a hook that is running during an upgrade never reads a half-written script.
|
|
168
|
+
* @param source - The hook inside the package.
|
|
169
|
+
* @param destination - Where it goes, inside the data directory.
|
|
170
|
+
* @param platform - The platform, for tests; defaults to this one.
|
|
171
|
+
* @returns True when the copy changed, so `init` can say the hook was refreshed.
|
|
172
|
+
* @throws {Error} If the destination is a symbolic link. Its contents are executed on every reply,
|
|
173
|
+
* and `init` only ever writes a regular file there (D-050).
|
|
174
|
+
*/
|
|
175
|
+
export function installHook(source, destination, platform = process.platform) {
|
|
176
|
+
if (existsSync(destination) && lstatSync(destination).isSymbolicLink()) {
|
|
177
|
+
throw new Error(`${destination} is a symbolic link; refusing to write the hook through it`);
|
|
178
|
+
}
|
|
179
|
+
const wanted = readFileSync(source);
|
|
180
|
+
if (existsSync(destination) && readFileSync(destination).equals(wanted)) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
// Same directory as the destination, so the rename is atomic.
|
|
184
|
+
const temp = `${destination}.tmp-${process.pid}-${Date.now()}`;
|
|
185
|
+
try {
|
|
186
|
+
writeFileSync(temp, wanted, { mode: PRIVATE_FILE_MODE });
|
|
187
|
+
if (hasPosixModes(platform)) {
|
|
188
|
+
// writeFile's mode is filtered by the umask; chmod makes it exact.
|
|
189
|
+
chmodSync(temp, PRIVATE_FILE_MODE);
|
|
190
|
+
}
|
|
191
|
+
renameSync(temp, destination);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
rmSync(temp, { force: true });
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Removes a file or directory tree, then checks that it is gone.
|
|
201
|
+
*
|
|
202
|
+
* Not `rmSync`: on Windows it silently removes nothing when any part of the path holds a non-ASCII
|
|
203
|
+
* character, and `force: true` hides that it did nothing (observed on Node 24.12, win32; not
|
|
204
|
+
* reproducible on macOS Node 24.21). `unlinkSync` and `rmdirSync` work on those same paths, so the
|
|
205
|
+
* tree is walked and taken apart with them. The check afterwards is the part that matters: a
|
|
206
|
+
* deletion this code cannot verify is reported as a survivor, never as a success (D-061).
|
|
207
|
+
* @param path - The file or directory to remove.
|
|
208
|
+
* @returns True when nothing is at the path afterwards.
|
|
209
|
+
*/
|
|
210
|
+
export function removePath(path) {
|
|
211
|
+
try {
|
|
212
|
+
if (!existsSync(path)) {
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
if (lstatSync(path).isDirectory()) {
|
|
216
|
+
for (const name of readdirSync(path)) {
|
|
217
|
+
removePath(join(path, name));
|
|
218
|
+
}
|
|
219
|
+
rmdirSync(path);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
unlinkSync(path);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// Whatever went wrong, the answer is the same question: is it still there?
|
|
227
|
+
}
|
|
228
|
+
return !existsSync(path);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Removes the files Nilometer keeps in a data directory, and the directory if nothing else is left.
|
|
232
|
+
*
|
|
233
|
+
* Only the names Nilometer writes are removed ({@link DATA_DIR_FILES}, {@link DATA_DIR_SUBDIRS}),
|
|
234
|
+
* and only what is confirmed gone is reported as removed.
|
|
235
|
+
* A user can point `--data-dir` at a folder that holds other things, and deleting a directory
|
|
236
|
+
* because of what it is called would take those with it (D-043 leaves shared folders alone). What
|
|
237
|
+
* was left behind is reported rather than removed (R2.5).
|
|
238
|
+
* @param dataDir - The data directory.
|
|
239
|
+
* @returns What was removed and what was left.
|
|
240
|
+
*/
|
|
241
|
+
export function deleteDataFiles(dataDir) {
|
|
242
|
+
const removed = [];
|
|
243
|
+
const failed = [];
|
|
244
|
+
if (!existsSync(dataDir)) {
|
|
245
|
+
return { removed, failed, directoryRemoved: false, kept: [] };
|
|
246
|
+
}
|
|
247
|
+
const ours = new Set([...DATA_DIR_FILES, ...DATA_DIR_SUBDIRS]);
|
|
248
|
+
for (const name of readdirSync(dataDir)) {
|
|
249
|
+
if (ours.has(name)) {
|
|
250
|
+
// Only what is confirmed gone counts as removed: claiming otherwise is the defect (D-061).
|
|
251
|
+
(removePath(join(dataDir, name)) ? removed : failed).push(name);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const left = readdirSync(dataDir);
|
|
255
|
+
const kept = left.filter((name) => !failed.includes(name));
|
|
256
|
+
const directoryRemoved = left.length === 0 && removePath(dataDir);
|
|
257
|
+
return { removed, failed, directoryRemoved, kept };
|
|
258
|
+
}
|
|
259
|
+
//# sourceMappingURL=private-files.js.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The install record: what `init` changed, so `uninstall` can put it back.
|
|
3
|
+
*
|
|
4
|
+
* Implements the `statusline-collector` skill's `init`/`uninstall` rules. Two files in the data
|
|
5
|
+
* directory:
|
|
6
|
+
* - `install-record.json`: the settings path, whether it existed, the backup path, and the user's
|
|
7
|
+
* original `statusLine` entry (or null).
|
|
8
|
+
* - `wrapped-command`: the original command as plain text, the only thing the shell hook reads.
|
|
9
|
+
* It exists only when there was an original command.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { ensurePrivateDir, removePath, tightenMode } from "./private-files.js";
|
|
14
|
+
import { writeFileAtomic } from "../settings/settings-file.js";
|
|
15
|
+
import { isCommandEntry } from "../settings/statusline.js";
|
|
16
|
+
/** File name of the install record inside the data directory. */
|
|
17
|
+
export const RECORD_FILE = "install-record.json";
|
|
18
|
+
/** File name of the wrapped command the hook runs (see hooks/statusline.sh). */
|
|
19
|
+
export const WRAPPED_COMMAND_FILE = "wrapped-command";
|
|
20
|
+
/** An install record that exists but can't be trusted. Nothing is changed when this is raised. */
|
|
21
|
+
export class InstallRecordError extends Error {
|
|
22
|
+
/**
|
|
23
|
+
* Creates an install record error.
|
|
24
|
+
* @param path - Path of the unreadable record.
|
|
25
|
+
* @param reason - What is wrong with it.
|
|
26
|
+
*/
|
|
27
|
+
constructor(path, reason) {
|
|
28
|
+
super(`install record ${path} is unusable: ${reason}`);
|
|
29
|
+
this.name = "InstallRecordError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Writes the install record and the hook's wrapped-command file.
|
|
34
|
+
*
|
|
35
|
+
* Called before settings are changed, so the hook finds the user's original command on its very
|
|
36
|
+
* first run and the status bar never shows the default in between.
|
|
37
|
+
* @param dataDir - The tool's data directory; created if missing.
|
|
38
|
+
* @param record - The record to write.
|
|
39
|
+
*/
|
|
40
|
+
export function writeInstallRecord(dataDir, record) {
|
|
41
|
+
ensurePrivateDir(dataDir);
|
|
42
|
+
writeFileAtomic(join(dataDir, RECORD_FILE), `${JSON.stringify(record, null, 2)}\n`);
|
|
43
|
+
const wrappedPath = join(dataDir, WRAPPED_COMMAND_FILE);
|
|
44
|
+
if (record.original === null) {
|
|
45
|
+
// A stale file from an earlier install would make the hook run a command that's gone.
|
|
46
|
+
removePath(wrappedPath);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
writeFileAtomic(wrappedPath, record.original.command);
|
|
50
|
+
}
|
|
51
|
+
// writeFileAtomic keeps an existing file's mode; an install from before D-043 may have left 0644.
|
|
52
|
+
tightenMode(join(dataDir, RECORD_FILE));
|
|
53
|
+
tightenMode(wrappedPath);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Reads the install record, validating its shape.
|
|
57
|
+
* @param dataDir - The tool's data directory.
|
|
58
|
+
* @returns The record, or null if none exists.
|
|
59
|
+
* @throws {InstallRecordError} If the file exists but isn't a valid record.
|
|
60
|
+
*/
|
|
61
|
+
export function readInstallRecord(dataDir) {
|
|
62
|
+
const path = join(dataDir, RECORD_FILE);
|
|
63
|
+
if (!existsSync(path)) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
// The record decides what `uninstall` writes back into settings.json, so a link to a file
|
|
67
|
+
// Nilometer didn't write is refused rather than followed (D-050).
|
|
68
|
+
if (lstatSync(path).isSymbolicLink()) {
|
|
69
|
+
throw new InstallRecordError(path, "a symbolic link, not the file init wrote");
|
|
70
|
+
}
|
|
71
|
+
let parsed;
|
|
72
|
+
try {
|
|
73
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new InstallRecordError(path, "not valid JSON");
|
|
77
|
+
}
|
|
78
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
79
|
+
throw new InstallRecordError(path, "not a JSON object");
|
|
80
|
+
}
|
|
81
|
+
const record = parsed;
|
|
82
|
+
// Validate every field uninstall relies on; a guess here could restore the wrong settings.
|
|
83
|
+
const valid = record["version"] === 1 &&
|
|
84
|
+
typeof record["settingsPath"] === "string" &&
|
|
85
|
+
typeof record["settingsExisted"] === "boolean" &&
|
|
86
|
+
(record["backupPath"] === null || typeof record["backupPath"] === "string") &&
|
|
87
|
+
(record["original"] === null || isCommandEntry(record["original"])) &&
|
|
88
|
+
typeof record["installedAt"] === "string";
|
|
89
|
+
if (!valid) {
|
|
90
|
+
throw new InstallRecordError(path, "missing or mistyped fields");
|
|
91
|
+
}
|
|
92
|
+
return record;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Removes the install record and wrapped-command file, keeping all collected data.
|
|
96
|
+
* @param dataDir - The tool's data directory.
|
|
97
|
+
*/
|
|
98
|
+
export function removeInstallRecord(dataDir) {
|
|
99
|
+
// removePath, not rmSync: rmSync silently removes nothing on a non-ASCII Windows path (D-061).
|
|
100
|
+
removePath(join(dataDir, RECORD_FILE));
|
|
101
|
+
removePath(join(dataDir, WRAPPED_COMMAND_FILE));
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=record.js.map
|