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,136 @@
1
+ /**
2
+ * @file Repository aliases the user enters, so a renamed or moved project counts as one (D-092).
3
+ *
4
+ * Nothing in the logs links a repository's old path to its new one (D-028). The user enters the
5
+ * link with `nilometer alias set <old> <new>`; `request_repositories` then counts the old path's
6
+ * requests under the new one and keeps the old path beside each, so every number still traces back.
7
+ * Like plan prices, these rows are user data: rebuilds never touch them.
8
+ */
9
+ import { isAbsolute, posix, win32 } from "node:path";
10
+ import { transaction } from "../db/database.js";
11
+ /** An alias that can't be stored or removed; its message says why. */
12
+ export class RepositoryAliasError extends Error {
13
+ /**
14
+ * Creates the error.
15
+ * @param message - What's wrong.
16
+ */
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = "RepositoryAliasError";
20
+ }
21
+ }
22
+ /**
23
+ * Turns a path the user typed into the form repositories are stored in: `~/` expanded against the
24
+ * home directory, and no trailing separator. A Windows path keeps its drive letter (D-049).
25
+ * @param path - The path as typed.
26
+ * @param home - The home directory, for `~/`.
27
+ * @returns The path, ready to compare with stored repositories.
28
+ * @throws {RepositoryAliasError} If it isn't absolute after `~/` is expanded.
29
+ */
30
+ export function normalizeRepositoryPath(path, home) {
31
+ const typed = path.trim();
32
+ const expanded = typed === "~" || typed.startsWith("~/") ? home + typed.slice(1) : typed;
33
+ if (!isAbsolute(expanded) && !win32.isAbsolute(expanded) && !posix.isAbsolute(expanded)) {
34
+ throw new RepositoryAliasError(`a repository path must be absolute, got "${typed}"`);
35
+ }
36
+ // A trailing separator would never match a stored repository; the root itself keeps its own.
37
+ return expanded.length > 1 ? expanded.replace(/[\\/]+$/, "") : expanded;
38
+ }
39
+ /**
40
+ * Validates an entry from user input.
41
+ * @param from - The old path, as typed.
42
+ * @param to - The new path, as typed.
43
+ * @param home - The home directory, for `~/`.
44
+ * @returns Both paths normalized.
45
+ * @throws {RepositoryAliasError} If either isn't absolute, or both are the same path.
46
+ */
47
+ export function validateAlias(from, to, home) {
48
+ const entry = {
49
+ from: normalizeRepositoryPath(from, home),
50
+ to: normalizeRepositoryPath(to, home),
51
+ };
52
+ if (entry.from === entry.to) {
53
+ throw new RepositoryAliasError(`an alias joins two different paths; both are ${entry.from}`);
54
+ }
55
+ return entry;
56
+ }
57
+ /**
58
+ * Stores an alias, replacing any for the same old path.
59
+ * @param db - Open, migrated database.
60
+ * @param entry - A validated entry.
61
+ * @param now - Clock for `entered_at`.
62
+ * @returns The alias it replaced, or null when the old path had none.
63
+ * @throws {RepositoryAliasError} If no request is attributed to the old path, or the alias would
64
+ * chain: its new path is another alias's old one, or its old path is another alias's new one.
65
+ */
66
+ export function setAlias(db, entry, now) {
67
+ return transaction(db, () => {
68
+ // Attributed before any alias: the path a request had, whether or not an alias moved it since.
69
+ const recorded = db
70
+ .prepare("SELECT 1 FROM request_repositories WHERE COALESCE(aliased_from, repository) = ? LIMIT 1")
71
+ .get(entry.from);
72
+ if (recorded === undefined) {
73
+ throw new RepositoryAliasError(`no Claude Code request is recorded under ${entry.from}; nilometer report --json lists each repository's full path`);
74
+ }
75
+ // One step only: a chain or a loop would make "counts under" depend on the order rows are read.
76
+ const chained = db
77
+ .prepare(`SELECT from_repository, to_repository FROM repository_aliases
78
+ WHERE (from_repository = :to) OR (to_repository = :from AND from_repository <> :from)
79
+ LIMIT 1`)
80
+ .get({ from: entry.from, to: entry.to });
81
+ if (chained !== undefined) {
82
+ throw new RepositoryAliasError(`aliases go one step: ${chained.from_repository} already counts under ${chained.to_repository}`);
83
+ }
84
+ const previous = readRow(db, entry.from);
85
+ db.prepare(`INSERT INTO repository_aliases (from_repository, to_repository, entered_at) VALUES (?, ?, ?)
86
+ ON CONFLICT (from_repository) DO UPDATE SET
87
+ to_repository = excluded.to_repository, entered_at = excluded.entered_at`).run(entry.from, entry.to, now().toISOString());
88
+ return previous;
89
+ });
90
+ }
91
+ /**
92
+ * Removes the alias for an old path.
93
+ * @param db - Open, migrated database.
94
+ * @param from - The old path, normalized.
95
+ * @returns The alias removed.
96
+ * @throws {RepositoryAliasError} If there's no alias for it.
97
+ */
98
+ export function removeAlias(db, from) {
99
+ const previous = readRow(db, from);
100
+ if (previous === null) {
101
+ throw new RepositoryAliasError(`there's no alias for ${from}`);
102
+ }
103
+ db.prepare("DELETE FROM repository_aliases WHERE from_repository = ?").run(from);
104
+ return previous;
105
+ }
106
+ /**
107
+ * Lists every alias, by old path.
108
+ * @param db - Open, migrated database.
109
+ * @returns The rows.
110
+ */
111
+ export function listAliases(db) {
112
+ return db
113
+ .prepare("SELECT from_repository, to_repository, entered_at FROM repository_aliases ORDER BY from_repository")
114
+ .all().map(toRow);
115
+ }
116
+ /**
117
+ * Reads the alias for one old path.
118
+ * @param db - Open, migrated database.
119
+ * @param from - The old path.
120
+ * @returns The row, or null when there is none.
121
+ */
122
+ function readRow(db, from) {
123
+ const row = db
124
+ .prepare("SELECT from_repository, to_repository, entered_at FROM repository_aliases WHERE from_repository = ?")
125
+ .get(from);
126
+ return row === undefined ? null : toRow(row);
127
+ }
128
+ /**
129
+ * Converts a stored row.
130
+ * @param row - The row as SQLite returns it.
131
+ * @returns The alias.
132
+ */
133
+ function toRow(row) {
134
+ return { from: row.from_repository, to: row.to_repository, enteredAt: row.entered_at };
135
+ }
136
+ //# sourceMappingURL=repository-aliases.js.map
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @file Loading the dated price table and syncing it into the database (docs/development.md P5.1, D-005, D-020).
3
+ *
4
+ * `prices.json` is the source of truth: committed, reviewed, and read from Anthropic's pricing page
5
+ * on the date each row records. The loader validates every field strictly, because a wrong rate
6
+ * silently mispriced every tool studied (D-005), and replaces the `prices` table on each ingest so a
7
+ * correction takes effect without a migration.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { transaction } from "../db/database.js";
11
+ /** A price table that fails validation. Nothing is written when this is raised. */
12
+ export class PriceTableError extends Error {
13
+ /**
14
+ * Creates a price table error.
15
+ * @param message - Which row and field are invalid.
16
+ */
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = "PriceTableError";
20
+ }
21
+ }
22
+ /** Rate fields every rates object must have. */
23
+ const RATE_FIELDS = ["input", "output", "cache_write_5m", "cache_write_1h", "cache_read"];
24
+ /** A `YYYY-MM-DD` date string. */
25
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
26
+ /**
27
+ * Validates a rates object.
28
+ * @param value - The candidate value.
29
+ * @param where - Location for error messages.
30
+ * @returns The rates.
31
+ * @throws {PriceTableError} If any rate is missing, not a number, or negative.
32
+ */
33
+ export function validateRates(value, where) {
34
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
35
+ throw new PriceTableError(`${where} must be an object`);
36
+ }
37
+ const record = value;
38
+ for (const field of RATE_FIELDS) {
39
+ const rate = record[field];
40
+ if (typeof rate !== "number" || !Number.isFinite(rate) || rate < 0) {
41
+ throw new PriceTableError(`${where}.${field} must be a non-negative number`);
42
+ }
43
+ }
44
+ return {
45
+ input: record["input"],
46
+ output: record["output"],
47
+ cache_write_5m: record["cache_write_5m"],
48
+ cache_write_1h: record["cache_write_1h"],
49
+ cache_read: record["cache_read"],
50
+ };
51
+ }
52
+ /**
53
+ * Validates a parsed price table document.
54
+ * @param document - Parsed JSON with a `rows` array.
55
+ * @returns Validated rows.
56
+ * @throws {PriceTableError} If the document or any row is invalid, or a (model, effective_from)
57
+ * pair repeats.
58
+ */
59
+ export function validatePriceTable(document) {
60
+ const rows = document?.rows;
61
+ if (!Array.isArray(rows)) {
62
+ throw new PriceTableError("price table must have a rows array");
63
+ }
64
+ const seen = new Set();
65
+ return rows.map((row, index) => {
66
+ const where = `rows[${index}]`;
67
+ const rates = validateRates(row, where);
68
+ const record = row;
69
+ /**
70
+ * Reads a required non-empty string field of this row.
71
+ * @param field - Field name.
72
+ * @returns The string.
73
+ * @throws {PriceTableError} If the field is missing, empty, or not a string.
74
+ */
75
+ const text = (field) => {
76
+ const value = record[field];
77
+ if (typeof value !== "string" || value === "") {
78
+ throw new PriceTableError(`${where}.${field} must be a non-empty string`);
79
+ }
80
+ return value;
81
+ };
82
+ const modelId = text("model_id");
83
+ const effectiveFrom = text("effective_from");
84
+ const verifiedOn = text("verified_on");
85
+ if (!DAY.test(effectiveFrom) || !DAY.test(verifiedOn)) {
86
+ throw new PriceTableError(`${where} dates must be YYYY-MM-DD`);
87
+ }
88
+ const key = `${modelId}@${effectiveFrom}`;
89
+ // Two rows for the same model and day would make the rate in effect ambiguous.
90
+ if (seen.has(key)) {
91
+ throw new PriceTableError(`${where} repeats ${key}`);
92
+ }
93
+ seen.add(key);
94
+ const max = record["standard_rate_max_input_tokens"];
95
+ if (max !== null && (typeof max !== "number" || !Number.isInteger(max) || max <= 0)) {
96
+ throw new PriceTableError(`${where}.standard_rate_max_input_tokens must be null or a positive integer`);
97
+ }
98
+ return {
99
+ model_id: modelId,
100
+ effective_from: effectiveFrom,
101
+ ...rates,
102
+ fast: record["fast"] === null ? null : validateRates(record["fast"], `${where}.fast`),
103
+ standard_rate_max_input_tokens: max,
104
+ source_url: text("source_url"),
105
+ verified_on: verifiedOn,
106
+ };
107
+ });
108
+ }
109
+ /**
110
+ * Reads and validates the price table file.
111
+ * @param path - Path to `prices.json`.
112
+ * @returns Validated rows.
113
+ * @throws {PriceTableError} If the file isn't valid JSON or fails validation.
114
+ */
115
+ export function loadPriceTable(path) {
116
+ let document;
117
+ try {
118
+ document = JSON.parse(readFileSync(path, "utf8"));
119
+ }
120
+ catch (error) {
121
+ throw new PriceTableError(`${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
122
+ }
123
+ return validatePriceTable(document);
124
+ }
125
+ /**
126
+ * Replaces the database's price rows with the given table, in one transaction.
127
+ * @param db - Open, migrated database.
128
+ * @param rows - Validated rows.
129
+ * @returns Number of rows written.
130
+ */
131
+ export function syncPrices(db, rows) {
132
+ const insert = db.prepare(`INSERT INTO prices (model_id, effective_from, input_per_mtok, output_per_mtok,
133
+ cache_write_5m_per_mtok, cache_write_1h_per_mtok, cache_read_per_mtok,
134
+ fast_input_per_mtok, fast_output_per_mtok, fast_cache_write_5m_per_mtok,
135
+ fast_cache_write_1h_per_mtok, fast_cache_read_per_mtok,
136
+ standard_rate_max_input_tokens, source_url, verified_on)
137
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
138
+ transaction(db, () => {
139
+ // The file is the whole truth: rows removed from it must disappear from the table too.
140
+ db.prepare("DELETE FROM prices").run();
141
+ for (const row of rows) {
142
+ insert.run(row.model_id, row.effective_from, row.input, row.output, row.cache_write_5m, row.cache_write_1h, row.cache_read, row.fast?.input ?? null, row.fast?.output ?? null, row.fast?.cache_write_5m ?? null, row.fast?.cache_write_1h ?? null, row.fast?.cache_read ?? null, row.standard_rate_max_input_tokens, row.source_url, row.verified_on);
143
+ }
144
+ });
145
+ return rows.length;
146
+ }
147
+ //# sourceMappingURL=prices.js.map
@@ -0,0 +1,215 @@
1
+ /**
2
+ * @file Reading, backing up, and atomically writing Claude Code's `settings.json`.
3
+ *
4
+ * Implements README § How it works ("Chaining, not clobbering") and the `statusline-collector`
5
+ * skill's `init` rules. The settings file belongs to the user, so every operation here:
6
+ * - backs up before writing,
7
+ * - refuses to "fix" a file it can't parse (report, don't fix),
8
+ * - writes atomically (temp file + rename), so a crash never leaves a half-written file,
9
+ * - keeps the file's key order, indentation, trailing newline, and permissions, so the diff
10
+ * shows only the change that was made.
11
+ */
12
+ import { chmodSync, copyFileSync, existsSync, lstatSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync, } from "node:fs";
13
+ import { basename, dirname, join } from "node:path";
14
+ import { removePath } from "../install/private-files.js";
15
+ import { expandHome } from "../util/paths.js";
16
+ /** A settings file that exists but can't be safely edited. The file is never modified. */
17
+ export class SettingsError extends Error {
18
+ /** Machine-readable reason, used by `init` to choose its message. */
19
+ code;
20
+ /** Path of the offending file. */
21
+ path;
22
+ /**
23
+ * Creates a settings error.
24
+ * @param code - Machine-readable reason.
25
+ * @param path - Path of the offending file.
26
+ * @param message - Human-readable explanation.
27
+ */
28
+ constructor(code, path, message) {
29
+ super(message);
30
+ this.name = "SettingsError";
31
+ this.code = code;
32
+ this.path = path;
33
+ }
34
+ }
35
+ /**
36
+ * Decides which settings file `init` operates on.
37
+ *
38
+ * Order: an explicit `--settings` path, then `$CLAUDE_CONFIG_DIR/settings.json`, then
39
+ * `~/.claude/settings.json`. Project-level settings are never chosen: the hook is per user.
40
+ * @param options - Home directory, flag override, and environment.
41
+ * @returns Absolute or home-expanded path to the settings file (which may not exist yet).
42
+ */
43
+ export function locateSettings(options) {
44
+ if (options.override !== undefined && options.override !== "") {
45
+ return expandHome(options.override, options.home);
46
+ }
47
+ if (options.claudeConfigDir !== undefined && options.claudeConfigDir !== "") {
48
+ return join(expandHome(options.claudeConfigDir, options.home), "settings.json");
49
+ }
50
+ return join(options.home, ".claude", "settings.json");
51
+ }
52
+ /**
53
+ * Detects the indentation and trailing newline of a JSON text.
54
+ *
55
+ * Uses the first indented line; a file with no indented lines (e.g. `{}`) falls back to two
56
+ * spaces, the formatting Claude Code itself writes.
57
+ * @param raw - The file's text.
58
+ * @returns The layout to reuse when rewriting the file.
59
+ */
60
+ export function detectFormat(raw) {
61
+ const trailingNewline = raw.endsWith("\n");
62
+ // The first line that starts with whitespace followed by content defines the indent unit.
63
+ const match = /^([ \t]+)\S/m.exec(raw);
64
+ const leading = match?.[1];
65
+ if (leading === undefined) {
66
+ return { indent: 2, trailingNewline };
67
+ }
68
+ return { indent: leading.startsWith("\t") ? "\t" : leading.length, trailingNewline };
69
+ }
70
+ /**
71
+ * Reports whether a path is a symbolic link, without following it.
72
+ * @param path - Path to inspect.
73
+ * @returns True if the path itself is a symbolic link.
74
+ */
75
+ function isLink(path) {
76
+ try {
77
+ return lstatSync(path).isSymbolicLink();
78
+ }
79
+ catch {
80
+ // No such path, or its parent can't be read: not a link this code has to handle.
81
+ return false;
82
+ }
83
+ }
84
+ /**
85
+ * Reads and parses a settings file without modifying it.
86
+ * @param path - Path to the settings file.
87
+ * @returns `missing` if the file doesn't exist, otherwise the parsed settings with raw text.
88
+ * @throws {SettingsError} If the file is empty, isn't valid JSON, or isn't a JSON object.
89
+ */
90
+ export function readSettings(path) {
91
+ // A symbolic link to a file that isn't there yet would otherwise look like "no settings file",
92
+ // and `init` would create whatever the link points at, which may be any path on the machine
93
+ // (D-050). A link to an existing file is followed: that's how a dotfiles repository is set up.
94
+ if (!existsSync(path) && isLink(path)) {
95
+ throw new SettingsError("dangling-link", path, `${path} is a symbolic link to a file that doesn't exist; refusing to create its target`);
96
+ }
97
+ if (!existsSync(path)) {
98
+ return { kind: "missing", path };
99
+ }
100
+ const raw = readFileSync(path, "utf8");
101
+ // An empty file isn't silently treated as `{}`: that would be fixing the user's file.
102
+ if (raw.trim() === "") {
103
+ throw new SettingsError("empty", path, `${path} is empty; refusing to guess its contents`);
104
+ }
105
+ let parsed;
106
+ try {
107
+ parsed = JSON.parse(raw);
108
+ }
109
+ catch (error) {
110
+ const reason = error instanceof Error ? error.message : String(error);
111
+ throw new SettingsError("invalid-json", path, `${path} is not valid JSON: ${reason}`);
112
+ }
113
+ // Arrays and primitives are valid JSON but not settings; editing them could destroy data.
114
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
115
+ throw new SettingsError("not-an-object", path, `${path} does not contain a JSON object`);
116
+ }
117
+ return {
118
+ kind: "ok",
119
+ path,
120
+ settings: parsed,
121
+ raw,
122
+ format: detectFormat(raw),
123
+ };
124
+ }
125
+ /**
126
+ * Formats a UTC timestamp for use in a backup filename.
127
+ * @param now - The moment to format.
128
+ * @returns `YYYYMMDD-HHMMSS` in UTC, safe in filenames on every platform.
129
+ */
130
+ export function backupStamp(now) {
131
+ // toISOString is always UTC, so backups sort chronologically regardless of the machine's zone.
132
+ const iso = now.toISOString();
133
+ return `${iso.slice(0, 4)}${iso.slice(5, 7)}${iso.slice(8, 10)}-${iso.slice(11, 13)}${iso.slice(14, 16)}${iso.slice(17, 19)}`;
134
+ }
135
+ /**
136
+ * Copies the settings file to a timestamped backup next to it, never overwriting a backup.
137
+ * @param path - Path to the settings file.
138
+ * @param now - Current time, injected for deterministic names in tests.
139
+ * @returns Path of the backup created, or `null` if there was no file to back up.
140
+ */
141
+ export function backupSettings(path, now) {
142
+ if (!existsSync(path)) {
143
+ return null;
144
+ }
145
+ const base = `${path}.bak-${backupStamp(now)}`;
146
+ let candidate = base;
147
+ // Two runs within the same second must not overwrite each other's backup.
148
+ for (let suffix = 1; existsSync(candidate); suffix += 1) {
149
+ candidate = `${base}-${suffix}`;
150
+ }
151
+ copyFileSync(path, candidate);
152
+ // Settings can hold secrets (API keys in `env`), and a backup is one more copy: owner-only (D-043).
153
+ chmodSync(candidate, 0o600);
154
+ return candidate;
155
+ }
156
+ /**
157
+ * Serializes settings using a detected layout.
158
+ * @param settings - The settings object; key order is preserved.
159
+ * @param format - Indentation and trailing-newline style to use.
160
+ * @returns The JSON text to write.
161
+ */
162
+ export function serializeSettings(settings, format) {
163
+ const text = JSON.stringify(settings, null, format.indent);
164
+ return format.trailingNewline ? `${text}\n` : text;
165
+ }
166
+ /** The real filesystem implementation of {@link AtomicFs}. */
167
+ export const NODE_ATOMIC_FS = {
168
+ writeFileSync: (path, data, options) => {
169
+ writeFileSync(path, data, options);
170
+ },
171
+ renameSync: (from, to) => {
172
+ renameSync(from, to);
173
+ },
174
+ rmSync: (path) => {
175
+ // removePath, not rmSync: rmSync silently removes nothing on a non-ASCII Windows path, which
176
+ // would leave this temp file beside the settings file after a failed write (D-061).
177
+ removePath(path);
178
+ },
179
+ modeOf: (path) => (existsSync(path) ? statSync(path).mode & 0o777 : null),
180
+ chmodSync: (path, mode) => {
181
+ chmodSync(path, mode);
182
+ },
183
+ resolveLink: (path) => (isLink(path) ? realpathSync(path) : path),
184
+ };
185
+ /**
186
+ * Writes text to a file atomically: a temp file in the same directory, then a rename.
187
+ *
188
+ * Rename within one directory is atomic, so readers see either the old file or the new one,
189
+ * never a partial write. The original file's permissions are kept (settings may be 0600).
190
+ * @param path - Destination file.
191
+ * @param text - Complete new contents.
192
+ * @param fs - Filesystem operations; defaults to the real filesystem.
193
+ * @throws {Error} Whatever the filesystem throws. The temp file is removed first, and the
194
+ * original file is untouched unless the rename itself succeeded.
195
+ */
196
+ export function writeFileAtomic(path, text, fs = NODE_ATOMIC_FS) {
197
+ // A rename replaces a symbolic link with a regular file, which would quietly detach a settings
198
+ // file that a dotfiles repository links to. Write to what the link points at instead (D-050).
199
+ const destination = fs.resolveLink(path);
200
+ // Same directory as the destination: rename across filesystems isn't atomic.
201
+ const temp = join(dirname(destination), `.${basename(destination)}.tmp-${process.pid}-${Date.now()}`);
202
+ // An existing file keeps its mode; a new one is owner-only, since settings can hold secrets (D-043).
203
+ const mode = fs.modeOf(destination) ?? 0o600;
204
+ try {
205
+ fs.writeFileSync(temp, text, { mode });
206
+ // writeFile's mode is filtered by the umask; chmod makes the result match the original exactly.
207
+ fs.chmodSync(temp, mode);
208
+ fs.renameSync(temp, destination);
209
+ }
210
+ catch (error) {
211
+ fs.rmSync(temp, { force: true });
212
+ throw error;
213
+ }
214
+ }
215
+ //# sourceMappingURL=settings-file.js.map