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.
Files changed (78) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/LICENSE +21 -0
  3. package/README.md +462 -2
  4. package/core/pricing/prices.json +348 -0
  5. package/core/schema/001_ingestion.sql +57 -0
  6. package/core/schema/002_line_count.sql +7 -0
  7. package/core/schema/003_derived.sql +70 -0
  8. package/core/schema/004_requests_dedup.sql +50 -0
  9. package/core/schema/005_repositories.sql +25 -0
  10. package/core/schema/006_status_readings.sql +33 -0
  11. package/core/schema/007_pricing.sql +95 -0
  12. package/core/schema/008_interruptions.sql +263 -0
  13. package/core/schema/009_headroom.sql +84 -0
  14. package/core/schema/010_attribution.sql +106 -0
  15. package/core/schema/011_projections.sql +83 -0
  16. package/core/schema/012_unsplit_cache_writes.sql +49 -0
  17. package/core/schema/013_repository_revalidation.sql +104 -0
  18. package/core/schema/014_observed_readings.sql +204 -0
  19. package/core/schema/015_response_observations.sql +127 -0
  20. package/core/schema/016_windows_repository_paths.sql +41 -0
  21. package/core/schema/017_unattributed_usage_once.sql +47 -0
  22. package/core/schema/018_request_time_index.sql +21 -0
  23. package/core/schema/019_dedup_earliest_of_equals.sql +61 -0
  24. package/core/schema/020_limit_quota_fields.sql +131 -0
  25. package/core/schema/021_plan_periods.sql +121 -0
  26. package/core/schema/022_attachment_walk.sql +223 -0
  27. package/core/schema/023_auto_continuations.sql +129 -0
  28. package/core/schema/024_counted_limit_groups.sql +29 -0
  29. package/core/schema/025_model_fallback.sql +77 -0
  30. package/core/schema/026_changed_readings.sql +106 -0
  31. package/core/schema/027_weekly_crossings.sql +32 -0
  32. package/core/schema/028_stale_readings.sql +196 -0
  33. package/core/schema/029_lockout_sessions.sql +68 -0
  34. package/core/schema/030_repository_aliases.sql +81 -0
  35. package/core/schema/031_cte_cursor_budget.sql +153 -0
  36. package/dist/cli/main.cli.js +39 -0
  37. package/dist/cli/program.js +801 -0
  38. package/dist/core/db/database.js +182 -0
  39. package/dist/core/db/fingerprint.js +71 -0
  40. package/dist/core/ingest/attribution.js +131 -0
  41. package/dist/core/ingest/classify.js +382 -0
  42. package/dist/core/ingest/command.js +95 -0
  43. package/dist/core/ingest/derive.js +212 -0
  44. package/dist/core/ingest/discover.js +126 -0
  45. package/dist/core/ingest/ingest.js +160 -0
  46. package/dist/core/ingest/reader.js +89 -0
  47. package/dist/core/ingest/reset-time.js +97 -0
  48. package/dist/core/ingest/results.js +201 -0
  49. package/dist/core/ingest/spool.js +165 -0
  50. package/dist/core/ingest/store.js +99 -0
  51. package/dist/core/install/install.js +185 -0
  52. package/dist/core/install/locations.js +89 -0
  53. package/dist/core/install/private-files.js +259 -0
  54. package/dist/core/install/record.js +103 -0
  55. package/dist/core/plans/plan-prices.js +128 -0
  56. package/dist/core/plans/repository-aliases.js +136 -0
  57. package/dist/core/pricing/prices.js +147 -0
  58. package/dist/core/settings/settings-file.js +215 -0
  59. package/dist/core/settings/statusline.js +240 -0
  60. package/dist/core/util/commands.js +91 -0
  61. package/dist/core/util/node-version.js +58 -0
  62. package/dist/core/util/open-page.js +69 -0
  63. package/dist/core/util/paths.js +36 -0
  64. package/dist/core/verify/compare.js +77 -0
  65. package/dist/core/verify/verify.js +220 -0
  66. package/dist/viewer/explain.js +574 -0
  67. package/dist/viewer/format.js +150 -0
  68. package/dist/viewer/html-charts.js +1292 -0
  69. package/dist/viewer/html-contract.js +2 -0
  70. package/dist/viewer/html-data.js +718 -0
  71. package/dist/viewer/html.js +1654 -0
  72. package/dist/viewer/queries.js +128 -0
  73. package/dist/viewer/render.js +602 -0
  74. package/dist/viewer/report.js +120 -0
  75. package/dist/viewer/save.js +110 -0
  76. package/docs/requirements.md +113 -0
  77. package/hooks/statusline.sh +104 -0
  78. package/package.json +61 -4
@@ -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
@@ -0,0 +1,128 @@
1
+ /**
2
+ * @file Plan prices the user enters, shown beside API list price (D-027).
3
+ *
4
+ * Nothing on disk records which plan the user pays for or its price, so the user enters it with
5
+ * `plan-price set`. One row per month from which a price applies; entering the same month again
6
+ * replaces that row. These rows are user data: rebuilds never touch them, and they never belong in
7
+ * the repository.
8
+ */
9
+ import { join } from "node:path";
10
+ import { openDatabase, transaction } from "../db/database.js";
11
+ import { DATABASE_FILE } from "../ingest/command.js";
12
+ import { resolveDataDir } from "../install/locations.js";
13
+ import { securePrivateDataDir } from "../install/private-files.js";
14
+ /** An entry that can't be stored; its message names the field. */
15
+ export class PlanPriceError extends Error {
16
+ /**
17
+ * Creates the error.
18
+ * @param message - What's wrong with the entry.
19
+ */
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = "PlanPriceError";
23
+ }
24
+ }
25
+ /** `YYYY-MM` with a real month. */
26
+ const MONTH = /^\d{4}-(0[1-9]|1[0-2])$/;
27
+ /** Longest accepted plan name, so a pasted paragraph isn't stored as a name. */
28
+ export const MAX_PLAN_NAME_LENGTH = 100;
29
+ /**
30
+ * Validates an entry from user input.
31
+ * @param month - `YYYY-MM`.
32
+ * @param usd - Price text or number, e.g. `"200"` or `"17.50"`.
33
+ * @param planName - Plan name.
34
+ * @returns The entry with the name trimmed and the price as a number.
35
+ * @throws {PlanPriceError} If the month isn't `YYYY-MM`, the price isn't a finite non-negative
36
+ * decimal number, or the name is empty or longer than {@link MAX_PLAN_NAME_LENGTH}.
37
+ */
38
+ export function validatePlanPrice(month, usd, planName) {
39
+ if (!MONTH.test(month)) {
40
+ throw new PlanPriceError(`month must be YYYY-MM, got "${month}"`);
41
+ }
42
+ // Plain decimals only: "1e3", "0x10", and "" are numbers to Number() but not prices anyone types.
43
+ const text = String(usd).trim();
44
+ if (!/^\d+(\.\d+)?$/.test(text)) {
45
+ throw new PlanPriceError(`price must be a non-negative USD amount like 200 or 17.50, got "${text}"`);
46
+ }
47
+ const name = planName.trim();
48
+ if (name === "" || name.length > MAX_PLAN_NAME_LENGTH) {
49
+ throw new PlanPriceError(`plan name must be 1 to ${MAX_PLAN_NAME_LENGTH} characters`);
50
+ }
51
+ return { month, planName: name, usdPerMonth: Number(text) };
52
+ }
53
+ /**
54
+ * Stores an entry, replacing any row for the same month.
55
+ * @param db - Open, migrated database.
56
+ * @param entry - A validated entry.
57
+ * @param now - Clock for `entered_at`.
58
+ * @returns The row it replaced, or null when the month had none.
59
+ */
60
+ export function setPlanPrice(db, entry, now) {
61
+ return transaction(db, () => {
62
+ const previous = readRow(db, entry.month);
63
+ db.prepare(`INSERT INTO plan_prices (effective_month, plan_name, usd_per_month, entered_at) VALUES (?, ?, ?, ?)
64
+ ON CONFLICT (effective_month) DO UPDATE SET
65
+ plan_name = excluded.plan_name, usd_per_month = excluded.usd_per_month, entered_at = excluded.entered_at`).run(entry.month, entry.planName, entry.usdPerMonth, now().toISOString());
66
+ return previous;
67
+ });
68
+ }
69
+ /**
70
+ * Lists every stored plan price, oldest month first.
71
+ * @param db - Open, migrated database.
72
+ * @returns The rows.
73
+ */
74
+ export function listPlanPrices(db) {
75
+ return db
76
+ .prepare("SELECT effective_month, plan_name, usd_per_month, entered_at FROM plan_prices ORDER BY effective_month")
77
+ .all().map(toRow);
78
+ }
79
+ /**
80
+ * Converts a stored row to its API shape.
81
+ * @param row - The row from SQLite.
82
+ * @returns The plan price.
83
+ */
84
+ function toRow(row) {
85
+ return {
86
+ month: row.effective_month,
87
+ planName: row.plan_name,
88
+ usdPerMonth: row.usd_per_month,
89
+ enteredAt: row.entered_at,
90
+ };
91
+ }
92
+ /**
93
+ * Reads one month's row.
94
+ * @param db - Open, migrated database.
95
+ * @param month - `YYYY-MM`.
96
+ * @returns The row, or null.
97
+ */
98
+ function readRow(db, month) {
99
+ const row = db
100
+ .prepare("SELECT effective_month, plan_name, usd_per_month, entered_at FROM plan_prices WHERE effective_month = ?")
101
+ .get(month);
102
+ return row === undefined ? null : toRow(row);
103
+ }
104
+ /**
105
+ * Opens the data directory's database, runs an operation on it, and closes it.
106
+ * @param options - Where the database lives.
107
+ * @param use - The operation.
108
+ * @returns The database path, the operation's result, and any data paths made owner-only.
109
+ */
110
+ export function withPlanDatabase(options, use) {
111
+ const dataDir = resolveDataDir({
112
+ home: options.home,
113
+ env: options.env,
114
+ override: options.dataDirOverride,
115
+ });
116
+ // A plan price can be entered before init or the first ingest has created the directory; either
117
+ // way the directory and its files end up owner-only (D-043).
118
+ const tightened = securePrivateDataDir(dataDir);
119
+ const databasePath = join(dataDir, DATABASE_FILE);
120
+ const db = openDatabase(databasePath, join(options.packageRoot, "core", "schema"));
121
+ try {
122
+ return { databasePath, result: use(db), tightened };
123
+ }
124
+ finally {
125
+ db.close();
126
+ }
127
+ }
128
+ //# sourceMappingURL=plan-prices.js.map